From a566a42aec461c2f285886a5fbe4aa3f87a9e7f0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:22:14 +0200 Subject: [PATCH] fix(entitlements): gate remaining paid-feature UI, drop stale beta pricing copy (#921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(entitlements): gate remaining paid-feature UI and drop stale beta pricing copy Second post-cutover sweep. Non-payers still hit live controls that 403 at the server gate, plus dead plan names in the inbox guide: - document-inbox guide: remove "Gratis under beta för Open-användare / Pro-planen / Se priser" (plans that no longer exist); inbox stays free, AI extraction is pitched as part of the subscription - payslip send button (RunProgressBar): disabled + tooltip without email_send; the free PDF download next to it is untouched - skattekonto "Synkronisera nu": disabled + tooltip without skatteverket - invoice post-create send-now dialog: skipped without email_send (the invoice page's SendInvoiceDialog carries the upsell) - recurring-invoice auto_send checkbox: disabled + UpgradeNote without email_send, and force-unchecked so edit mode can't PATCH it back on - AgentChat composer: replaced with UpgradeNote without ai, covering deep links to /chat/* and conversations opened before expiry - /onboarding/agent server page: hasCapability(ai) redirect to /settings/billing before the gated composer stream starts Co-Authored-By: Claude Opus 4.7 * fix(entitlements): address review findings on the gating sweep - AgentChat: the mount effect auto-fired the first /api/agent/invoke on a fresh start regardless of capability; now returns early without ai, and Regenerate/Correction re-invokes are guarded too (real find by review bot) - recurring auto_send upsell copy moved to i18n (auto_send_requires_subscription, sv+en) matching the rest of the dialog - disabled-button tooltips wrapped in a span (payslip send + skattekonto sync): browsers suppress title on disabled elements Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- app/(dashboard)/skattekonto/page.tsx | 14 ++++++--- app/(onboarding)/onboarding/agent/page.tsx | 9 ++++++ components/agent/AgentChat.tsx | 20 ++++++++++++ .../general/InvoiceInboxWorkspace.tsx | 14 ++------- components/invoices/InvoiceEditor.tsx | 10 ++++-- .../invoices/NewRecurringScheduleDialog.tsx | 20 +++++++++--- components/salary/run/RunProgressBar.tsx | 31 ++++++++++++------- messages/en.json | 2 ++ messages/sv.json | 2 ++ 9 files changed, 87 insertions(+), 35 deletions(-) diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index d2e8f8a1..fd027135 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -27,6 +27,8 @@ import { Link2, RefreshCw, } from 'lucide-react' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { SkatteverketSaldoResponse, SkattekontoTransactionWithSuggestion, @@ -60,6 +62,7 @@ interface MatchCandidate { export default function SkattekontoPage() { const { toast } = useToast() + const hasSkvCapability = useCapability(CAPABILITY.skatteverket) const [saldo, setSaldo] = useState(null) const [tx, setTx] = useState(null) const [loading, setLoading] = useState(true) @@ -305,10 +308,13 @@ export default function SkattekontoPage() {
- - {syncing ? 'Synkroniserar…' : 'Synkronisera nu'} - + // The span carries the tooltip: `title` is suppressed on disabled elements. + + + } /> diff --git a/app/(onboarding)/onboarding/agent/page.tsx b/app/(onboarding)/onboarding/agent/page.tsx index 088d37d0..dcdffc5f 100644 --- a/app/(onboarding)/onboarding/agent/page.tsx +++ b/app/(onboarding)/onboarding/agent/page.tsx @@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { headers } from 'next/headers' import { getActiveCompanyId } from '@/lib/company/context' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch' import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding' @@ -21,6 +23,13 @@ export default async function AgentOnboardingPage() { const companyId = await getActiveCompanyId(supabase, user.id) if (!companyId) redirect('/onboarding') + // Paywall: the build flow drives the gated composer stream (ai capability). + // Send non-payers to billing where the assistant is pitched, instead of a + // build sequence that 403s mid-stream. + if (!(await hasCapability(supabase, companyId, CAPABILITY.ai))) { + redirect('/settings/billing') + } + // Everything that doesn't depend on the TIC snapshot loads in one batch: // the settings row (carrying the is_sandbox gate + the onboarding-form // data, moms_period, fiscal_year_start_month, f_skatt, city, …, that diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index b5af3c91..ed89d814 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -15,6 +15,9 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { UpgradeNote } from '@/components/billing/UpgradeNote' import ApprovalCard from './ApprovalCard' // Reusable chat surface: used both inside the right-hand AgentSheet and on @@ -123,6 +126,7 @@ export default function AgentChat({ const firstTurnFiredRef = useRef(false) const conversationIdRef = useRef(initialConversationId ?? null) const [messages, setMessages] = useState(initialMessages ?? []) + const hasAi = useCapability(CAPABILITY.ai) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) const [errorMessage, setErrorMessage] = useState(null) @@ -158,6 +162,10 @@ export default function AgentChat({ const hasResumeState = !!initialConversationId if (hasResumeState) return + // Paywall: never auto-fire the first invoke without the ai capability; + // the composer is already replaced by the upgrade note. + if (!hasAi) return + // Seed-message path: render the user's pre-baked starter in the timeline // and send it as the first turn's user_message (skips intent.capture + // promptTemplate). Empty seed runs the normal capture-driven flow. @@ -339,6 +347,7 @@ export default function AgentChat({ } function handleRegenerate() { + if (!hasAi) return // Re-run the last user message and let the agent produce a fresh // response. UI truncates back to the last user message; DB rows are // append-only, so the previous assistant turn stays in agent_messages @@ -361,6 +370,7 @@ export default function AgentChat({ // user turn so the agent re-proposes inline: no synthetic user bubble (we // don't add a user row, and the turn is persisted hidden). function handleCorrection(correctionMessage: string) { + if (!hasAi) return void startTurn({ conversationId, userMessage: correctionMessage, hidden: true }) } @@ -549,6 +559,7 @@ export default function AgentChat({ streamingTail={streaming && i === messages.length - 1} showRegenerate={ !streaming && + hasAi && i === lastAssistantIdx && m.role === 'assistant' && m.text.length > 0 @@ -566,6 +577,14 @@ export default function AgentChat({ )}
+ {/* Paywall: /api/agent/invoke 403s without the ai capability. Replace + the composer with an upsell so an already-open conversation (or a + deep link to /chat/*) never offers an input that can't send. */} + {!hasAi ? ( +
+ AI-assistenten kräver ett abonnemang. +
+ ) : (
+ )} ) } diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 39ddb3c8..07fabe37 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1353,18 +1353,8 @@ function OnboardingCard({

Underlagen samlas här (från mail eller filuppladdning) och kan - matchas mot bankhändelser eller bokföras direkt. -

-

- Gratis under beta för Open-användare. Ingår senare i Pro-planen.{' '} - - Se priser → - + matchas mot bankhändelser eller bokföras direkt. Inkorgen är alltid + gratis; AI-tolkning av underlag ingår i abonnemanget.

diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index a47635ec..4994011e 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -43,7 +43,8 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import CustomerForm from '@/components/customers/CustomerForm' import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog' import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt' -import { useCompany } from '@/contexts/CompanyContext' +import { useCompany, useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { ROT_WORK_TYPES, @@ -109,6 +110,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat const { toast } = useToast() const { canWrite } = useCanWrite() const { company } = useCompany() + const hasEmailSend = useCapability(CAPABILITY.email_send) const supabase = createClient() const t = useTranslations('invoice_editor') const ts = useTranslations('self_billing') @@ -929,7 +931,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat function handleLogoPromptClose() { setShowLogoPrompt(false) // Resume the post-create flow that was deferred by the logo prompt. - if (selectedCustomer?.email && createdInvoiceId) { + // The send-now dialog only emails: skipped without the email_send + // capability (the invoice page's SendInvoiceDialog carries the upsell). + if (selectedCustomer?.email && createdInvoiceId && hasEmailSend) { setShowSendPrompt(true) } else if (createdInvoiceId) { router.push(`/invoices/${createdInvoiceId}`) @@ -999,7 +1003,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat // prompt closes, handleLogoPromptClose resumes the regular flow. if (hadZeroInvoices === true && !logoUrl) { setShowLogoPrompt(true) - } else if (selectedCustomer?.email) { + } else if (selectedCustomer?.email && hasEmailSend) { setShowSendPrompt(true) } else { router.push(`/invoices/${result.data.id}`) diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx index cd9b4c96..6ae9942c 100644 --- a/components/invoices/NewRecurringScheduleDialog.tsx +++ b/components/invoices/NewRecurringScheduleDialog.tsx @@ -25,7 +25,9 @@ import { SelectValue, } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' -import { useCompany } from '@/contexts/CompanyContext' +import { useCompany, useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { UpgradeNote } from '@/components/billing/UpgradeNote' import { Plus, Trash2 } from 'lucide-react' import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types' import { formatCurrency } from '@/lib/utils' @@ -215,15 +217,18 @@ function NewRecurringScheduleForm({ const watchCustomerId = watch('customer_id') const selectedCustomer = customers.find((c) => c.id === watchCustomerId) const customerMissingEmail = !!selectedCustomer && !selectedCustomer.email + const hasEmailSend = useCapability(CAPABILITY.email_send) + const autoSendBlocked = customerMissingEmail || !hasEmailSend // The onValueChange guard on the customer select only fires on a manual // change. In edit mode a schedule can load with auto_send=true against a // customer who has since lost their email (customers load async, after the // form's defaultValues). Force auto_send off whenever the effective customer - // has no email so a disabled-but-checked box can't PATCH auto_send=true. + // has no email (or email sending isn't entitled) so a disabled-but-checked + // box can't PATCH auto_send=true. useEffect(() => { - if (customerMissingEmail) setValue('auto_send', false) - }, [customerMissingEmail, setValue]) + if (autoSendBlocked) setValue('auto_send', false) + }, [autoSendBlocked, setValue]) const subtotalRaw = items.reduce( (sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0), 0, @@ -371,7 +376,7 @@ function NewRecurringScheduleForm({ id="auto_send" checked={field.value} onChange={(e) => field.onChange(e.target.checked)} - disabled={customerMissingEmail} + disabled={autoSendBlocked} className="mt-1 h-4 w-4" /> )} @@ -388,6 +393,11 @@ function NewRecurringScheduleForm({ {t('auto_send_missing_email')}

)} + {!hasEmailSend && ( + + {t('auto_send_requires_subscription')} + + )} diff --git a/components/salary/run/RunProgressBar.tsx b/components/salary/run/RunProgressBar.tsx index d852299a..1f8913a9 100644 --- a/components/salary/run/RunProgressBar.tsx +++ b/components/salary/run/RunProgressBar.tsx @@ -4,6 +4,8 @@ import { useTranslations, useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react' import { formatDateLong } from '@/lib/utils' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { RunDetail } from './types' type StepState = 'done' | 'active' | 'upcoming' @@ -45,6 +47,7 @@ export function RunProgressBar(props: RunProgressBarProps) { const rank = STATUS_RANK[run.status] ?? 0 const busy = !!actionLoading const deliveries = run.payslip_deliveries_summary + const hasEmailSend = useCapability(CAPABILITY.email_send) function spinnerOr(icon: React.ReactNode, key: string) { return actionLoading === key ? : icon @@ -128,19 +131,25 @@ export function RunProgressBar(props: RunProgressBarProps) { const activeStep = steps.find(s => s.state === 'active') // Payslip send/download — shared by the mobile summary and the desktop bar. + // Email send is a paid capability (server 403s without it); the PDF + // download alternative right next to it stays free. const payslipActions = payslipsAvailable && canWrite && ( <> - + {/* The span carries the tooltip: browsers suppress `title` on + disabled elements, and hover events don't fire on them. */} + + +