diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index bc9898ad..34d0b58e 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -56,6 +56,7 @@ import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
+import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
@@ -146,6 +147,10 @@ interface NavItem {
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
// and APIs work regardless, dimensions plan §2).
requiresDimensions?: boolean
+ // Paywall surfaces: hidden unless the active company holds this paid
+ // capability. Cosmetic only, the page and API gates are the real
+ // enforcement; this just keeps the sidebar honest for non-payers.
+ requiredCapability?: CapabilityKey
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
@@ -164,7 +169,7 @@ const navItems: NavItem[] = [
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
- { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta' },
+ { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta', requiredCapability: EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox'] },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
@@ -225,7 +230,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
- const { company } = useCompany()
+ const { company, capabilities } = useCompany()
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
@@ -399,6 +404,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Dimension surfaces are hidden until the company opts in via the
// bookkeeping settings toggle (company_settings.dimensions_enabled).
if (item.requiresDimensions && !dimensionsEnabled) return false
+ // Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless
+ // the active company holds the capability. The page + API gates enforce
+ // the paywall; this keeps the sidebar from advertising a dead workspace.
+ if (item.requiredCapability && !capabilities.includes(item.requiredCapability)) return false
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).
diff --git a/extensions/general/enable-banking/__tests__/capability-gate.test.ts b/extensions/general/enable-banking/__tests__/capability-gate.test.ts
new file mode 100644
index 00000000..a0a183c8
--- /dev/null
+++ b/extensions/general/enable-banking/__tests__/capability-gate.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// Force the gate to run (no dev bypass) but stub requireCapability so we control
+// entitlement per test. Mirrors the skatteverket capability-gate suite.
+vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
+ const actual = await importOriginal
()
+ return { ...actual, requireCapability: vi.fn() }
+})
+
+import { enableBankingExtension } from '../index'
+import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
+import { CAPABILITY } from '@/lib/entitlements/keys'
+import type { ExtensionContext } from '@/lib/extensions/types'
+
+// The interactive bank-sync entry points gated on CAPABILITY.bank_sync.
+const GATED: Array<{ method: string; path: string }> = [
+ { method: 'POST', path: '/connect' },
+ { method: 'POST', path: '/sync' },
+]
+
+function makeContext(): ExtensionContext {
+ return {
+ userId: 'user-1',
+ companyId: 'company-1',
+ extensionId: 'enable-banking',
+ requestId: 'req_test',
+ supabase: {
+ auth: {
+ getUser: vi
+ .fn()
+ .mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
+ },
+ from: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any,
+ emit: vi.fn().mockResolvedValue(undefined),
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
+ settings: {
+ get: vi.fn().mockResolvedValue(null),
+ set: vi.fn().mockResolvedValue(undefined),
+ clear: vi.fn().mockResolvedValue(undefined),
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+}
+
+describe('enable-banking paywall gate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it.each(GATED)(
+ '$method $path returns 403 capability_blocked when not entitled',
+ async ({ method, path }) => {
+ vi.mocked(requireCapability).mockResolvedValue(
+ capabilityBlockedResponse(CAPABILITY.bank_sync),
+ )
+ const route = enableBankingExtension.apiRoutes?.find(
+ (r) => r.method === method && r.path === path,
+ )
+ expect(route, `${method} ${path} must be registered`).toBeDefined()
+
+ const request = new Request(
+ `https://test.local/api/extensions/ext/enable-banking${path}`,
+ {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ },
+ )
+ const response = await route!.handler(request, makeContext())
+
+ expect(response.status).toBe(403)
+ const body = (await response.json()) as {
+ capability_blocked?: boolean
+ capability?: string
+ }
+ expect(body.capability_blocked).toBe(true)
+ expect(body.capability).toBe(CAPABILITY.bank_sync)
+ },
+ )
+})
diff --git a/extensions/general/invoice-inbox/manifest.json b/extensions/general/invoice-inbox/manifest.json
index 781dcd38..704d1fba 100644
--- a/extensions/general/invoice-inbox/manifest.json
+++ b/extensions/general/invoice-inbox/manifest.json
@@ -17,6 +17,6 @@
"hasOwnData": true,
"readsCoreTables": ["document_attachments", "suppliers"],
"description": "Vidarebefordra leverantörsfakturor till en unik adress: dokumenten landar här med extraherade fält",
- "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt."
+ "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI. Kräver AI-funktionen i din prenumeration."
}
}
diff --git a/lib/entitlements/__tests__/extension-gating.test.ts b/lib/entitlements/__tests__/extension-gating.test.ts
new file mode 100644
index 00000000..a256e357
--- /dev/null
+++ b/lib/entitlements/__tests__/extension-gating.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import {
+ CAPABILITY,
+ EXTENSION_REQUIRED_CAPABILITY,
+ requiredCapabilityForExtension,
+} from '../keys'
+
+describe('requiredCapabilityForExtension', () => {
+ it('gates the invoice-inbox workspace on the AI capability', () => {
+ // The inbox exists to run AI field extraction; the sidebar item and the
+ // /e/[sector]/[slug] page both read this to hide/block for non-payers.
+ expect(requiredCapabilityForExtension('general', 'invoice-inbox')).toBe(CAPABILITY.ai)
+ })
+
+ it('returns undefined for extensions that stay open', () => {
+ expect(requiredCapabilityForExtension('general', 'enable-banking')).toBeUndefined()
+ expect(requiredCapabilityForExtension('general', 'tic')).toBeUndefined()
+ expect(requiredCapabilityForExtension('general', 'does-not-exist')).toBeUndefined()
+ })
+
+ it('keys the map by `${sector}/${slug}` so page and nav resolve identically', () => {
+ expect(EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox']).toBe(CAPABILITY.ai)
+ })
+})
diff --git a/lib/entitlements/__tests__/has-capability.test.ts b/lib/entitlements/__tests__/has-capability.test.ts
index d8a3de51..0691dce7 100644
--- a/lib/entitlements/__tests__/has-capability.test.ts
+++ b/lib/entitlements/__tests__/has-capability.test.ts
@@ -48,6 +48,30 @@ describe('hasCapability', () => {
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
})
+ it('development bypasses the gate (all-on) so gated features are testable without a subscription', async () => {
+ // This is WHY a lapsed company still sees paid surfaces under `npm run dev`.
+ vi.stubEnv('NODE_ENV', 'development')
+ const supabase = makeSupabase({}) // no grant: would be false if the gate ran
+ expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
+ })
+
+ it('FORCE_PAYWALL=true activates the real gate in development (fail-closed on an expired grant)', async () => {
+ vi.stubEnv('NODE_ENV', 'development') // would otherwise bypass
+ vi.stubEnv('FORCE_PAYWALL', 'true')
+ const supabase = makeSupabase({
+ companies: { data: { team_id: null } },
+ capability_grants: { data: [{ expires_at: iso(-60_000) }] }, // expired
+ })
+ expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
+ })
+
+ it('FORCE_PAYWALL never overrides self-hosted (stays all-on)', async () => {
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
+ vi.stubEnv('FORCE_PAYWALL', 'true')
+ const supabase = makeSupabase({}) // would resolve null/false if queried
+ expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
+ })
+
it('returns true for an unexpired company-scoped grant', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts
index 471c89f4..62cc8d38 100644
--- a/lib/entitlements/has-capability.ts
+++ b/lib/entitlements/has-capability.ts
@@ -33,8 +33,15 @@ function isSelfHosted(): boolean {
* production build. Never set this in a hosted environment.
*/
function isPaywallBypassed(): boolean {
+ // Self-hosted is genuinely all-on: never gate it.
+ if (isSelfHosted()) return true
+ // Escape hatch to exercise the REAL gate in local dev, where the paywall is
+ // otherwise all-on so every paid feature is testable without a subscription.
+ // Set FORCE_PAYWALL=true to see the paid/non-paid UX (nav hiding, page upsells)
+ // exactly as a non-payer would. Fail-safe: it can only make gating stricter, so
+ // it is harmless if it ever leaks into a hosted env. Wins over the dev bypass.
+ if (process.env.FORCE_PAYWALL === 'true') return false
return (
- isSelfHosted() ||
process.env.NODE_ENV === 'development' ||
process.env.DISABLE_PAYWALL === 'true'
)
diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts
index 74867ba8..09e75d84 100644
--- a/lib/entitlements/keys.ts
+++ b/lib/entitlements/keys.ts
@@ -89,3 +89,27 @@ export const PAID_OPERATION_CAPABILITY_MAP: Readonly>> = {
+ 'general/invoice-inbox': CAPABILITY.ai,
+} as const
+
+/** Which paid capability (if any) an extension workspace requires to be usable. */
+export function requiredCapabilityForExtension(
+ sector: string,
+ slug: string,
+): CapabilityKey | undefined {
+ return EXTENSION_REQUIRED_CAPABILITY[`${sector}/${slug}`]
+}
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index 5d098ba8..046a3b1f 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -98,7 +98,7 @@ export const EXTENSION_DEFINITIONS: Record = {
"icon": "Inbox",
"dataPattern": "both",
"description": "Vidarebefordra leverantörsfakturor till en unik adress: dokumenten landar här med extraherade fält",
- "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt.",
+ "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI. Kräver AI-funktionen i din prenumeration.",
"readsCoreTables": [
"document_attachments",
"suppliers"
diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts
index e76539cc..da7e9aeb 100644
--- a/lib/extensions/sectors.ts
+++ b/lib/extensions/sectors.ts
@@ -1,6 +1,8 @@
import type { Sector, SectorSlug, ExtensionDefinition } from './types'
import { EXTENSION_DEFINITIONS } from './_generated/sector-definitions'
import { WORKSPACES } from './_generated/workspace-map'
+import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
+import type { CapabilityKey } from '@/lib/entitlements/keys'
// ============================================================
// Sector & Extension Registry
@@ -40,6 +42,17 @@ export function getExtensionDefinition(sectorSlug: string, extensionSlug: string
return sector?.extensions.find(e => e.slug === extensionSlug)
}
+/**
+ * Paid capability an extension requires, resolved by its registry id (== slug).
+ * The API-route dispatcher keys off this so a paid extension is gated at the
+ * request chokepoint, reusing the same EXTENSION_REQUIRED_CAPABILITY map that
+ * hides its sidebar item and blocks its page (lib/entitlements/keys).
+ */
+export function requiredCapabilityForExtensionId(extensionId: string): CapabilityKey | undefined {
+ const ext = getAllExtensions().find(e => e.slug === extensionId)
+ return ext ? requiredCapabilityForExtension(ext.sector, ext.slug) : undefined
+}
+
export function getAllExtensions(): ExtensionDefinition[] {
return SECTORS.flatMap(s => s.extensions)
}
diff --git a/lib/worklist/__tests__/visible-total.test.ts b/lib/worklist/__tests__/visible-total.test.ts
new file mode 100644
index 00000000..7081ce5a
--- /dev/null
+++ b/lib/worklist/__tests__/visible-total.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { visibleWorklistTotal, visibleWorklistTotalFrom } from '../visible-total'
+import type { WorklistCounts } from '../types'
+
+describe('visibleWorklistTotal', () => {
+ it('subtracts inbox documents for non-payers so the count matches the hidden row', () => {
+ expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: false })).toBe(2)
+ })
+
+ it('keeps inbox documents for payers (the row is shown)', () => {
+ expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: true })).toBe(5)
+ })
+
+ it('adds dashboard-only extras (expiring bank connections)', () => {
+ expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: false, extra: 2 })).toBe(4)
+ })
+
+ it('clamps to 0 rather than rendering a negative count on a count skew', () => {
+ expect(visibleWorklistTotal({ total: 1, inboxDocumentCount: 3, hasAi: false })).toBe(0)
+ })
+
+ it('from() reads total + inbox_document off the worklist object', () => {
+ const worklist = { total: 4, counts: { inbox_document: 1 } } as unknown as WorklistCounts
+ expect(visibleWorklistTotalFrom(worklist, false, 1)).toBe(4) // 4 + 1 - 1
+ expect(visibleWorklistTotalFrom(worklist, true, 1)).toBe(5) // 4 + 1 - 0
+ })
+})
diff --git a/lib/worklist/index.ts b/lib/worklist/index.ts
index a905e1e8..3a7e835c 100644
--- a/lib/worklist/index.ts
+++ b/lib/worklist/index.ts
@@ -1,3 +1,4 @@
export * from './types'
export * from './categories'
export * from './aggregate'
+export * from './visible-total'
diff --git a/lib/worklist/visible-total.ts b/lib/worklist/visible-total.ts
new file mode 100644
index 00000000..cf616de8
--- /dev/null
+++ b/lib/worklist/visible-total.ts
@@ -0,0 +1,39 @@
+import type { WorklistCounts } from './types'
+
+/**
+ * The "Att göra" total as the user actually sees it. The raw worklist total
+ * counts inbox_document, but the Dokumentinkorg is a paid (AI) surface hidden
+ * from non-payers, so its documents must not inflate the count either, else the
+ * dashboard tile shows "N att göra" over a section that renders no such row.
+ *
+ * Single source for the KPI tile (DashboardContent) and the section header
+ * (AttGoraSection): both must agree, and a mismatch here was a real bug. `extra`
+ * carries dashboard-only additions (expiring bank connections) that are not a
+ * lib/worklist category.
+ */
+export function visibleWorklistTotal(params: {
+ total: number
+ inboxDocumentCount: number
+ hasAi: boolean
+ extra?: number
+}): number {
+ const { total, inboxDocumentCount, hasAi, extra = 0 } = params
+ // total already includes inbox_document, so the subtraction is >= 0 in normal
+ // operation; clamp anyway so a transient count skew can never render a
+ // nonsense negative "N att gora" on the dashboard tile.
+ return Math.max(0, total + extra - (hasAi ? 0 : inboxDocumentCount))
+}
+
+/** Convenience overload taking the whole counts object. */
+export function visibleWorklistTotalFrom(
+ worklist: WorklistCounts,
+ hasAi: boolean,
+ extra = 0,
+): number {
+ return visibleWorklistTotal({
+ total: worklist.total,
+ inboxDocumentCount: worklist.counts.inbox_document,
+ hasAi,
+ extra,
+ })
+}
diff --git a/messages/en.json b/messages/en.json
index 0e84194a..43b4ee8f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3708,6 +3708,9 @@
"data_source_heading": "Data source",
"reads_from": "Reads from: {tables}",
"open": "Open",
+ "upsell_title": "Upgrade to use {name}",
+ "upsell_description": "This feature is included in a paid subscription. Upgrade to enable it.",
+ "upsell_cta": "Upgrade",
"breadcrumb": "Extensions",
"extension_count": "{count} extensions",
"data_pattern_core": "Uses bookkeeping data",
@@ -3742,7 +3745,7 @@
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from accounted. Save drafts, validate, lock and sign: without leaving the app.",
"ext_invoice_inbox_name": "Document inbox",
"ext_invoice_inbox_description": "Forward supplier invoices to a unique address: documents land here with extracted fields",
- "ext_invoice_inbox_long_description": "Each company gets a unique invoice inbox address. Invoices sent there are captured automatically and fields such as org. no., OCR, bankgiro, amount and due date are extracted deterministically from the PDF text. No AI calls, no cloud services beyond Resend for email delivery."
+ "ext_invoice_inbox_long_description": "Each company gets a unique invoice inbox address. Invoices sent there are captured automatically and fields such as org. no., OCR, bankgiro, amount and due date are read with AI. Requires the AI feature in your subscription."
},
"fiscal_year": {
"label": "Fiscal year",
diff --git a/messages/sv.json b/messages/sv.json
index 45ed8d1e..af252cf4 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -3708,6 +3708,9 @@
"data_source_heading": "Datakälla",
"reads_from": "Läser från: {tables}",
"open": "Öppna",
+ "upsell_title": "Uppgradera för att använda {name}",
+ "upsell_description": "Den här funktionen ingår i en betald prenumeration. Uppgradera för att aktivera den.",
+ "upsell_cta": "Uppgradera",
"breadcrumb": "Tillägg",
"extension_count": "{count} tillägg",
"data_pattern_core": "Använder bokföringsdata",
@@ -3742,7 +3745,7 @@
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från accounted. Spara utkast, validera, lås och signera: utan att lämna appen.",
"ext_invoice_inbox_name": "Dokumentinkorg",
"ext_invoice_inbox_description": "Vidarebefordra leverantörsfakturor till en unik adress: dokumenten landar här med extraherade fält",
- "ext_invoice_inbox_long_description": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning."
+ "ext_invoice_inbox_long_description": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI. Kräver AI-funktionen i din prenumeration."
},
"fiscal_year": {
"label": "Räkenskapsår",