-
- {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 ? (
+
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. */}
+
+
+