From 19cbb0094b4b30851f07650f8b590b6209775a47 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:20:39 +0200 Subject: [PATCH] fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924) The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the sidebar, command palette, and home "Att gora" list, its page directly reachable, and every non-AI HTTP route open. Its whole value is AI field extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint elsewhere, so gate the whole surface on CAPABILITY.ai. - EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the single source the nav item, the page, and the API dispatcher all read. - Hide the sidebar item, command-palette entry, and home inbox row for non-payers; subtract inbox_document from the "Att gora" total via one shared visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0. - Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState. - Enforce the capability in the extension API dispatcher (the single chokepoint that already enforces MFA), so every company-context inbox route 403s. The skipAuth /inbound webhook stays open (freeze-and-retain). - FORCE_PAYWALL=true override so the real gate is exercisable in local dev. - Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt, visibleWorklistTotal, and enable-banking /connect + /sync 403. Co-authored-by: Claude Fable 5 --- app/(dashboard)/e/[sector]/[slug]/page.tsx | 36 ++++++ .../ext/[...path]/__tests__/route.test.ts | 110 ++++++++++++++++++ app/api/extensions/ext/[...path]/route.ts | 19 +++ components/common/CommandPalette.tsx | 31 +++-- components/dashboard/AttGoraSection.tsx | 26 ++++- components/dashboard/DashboardContent.tsx | 13 ++- components/dashboard/DashboardNav.tsx | 13 ++- .../__tests__/capability-gate.test.ts | 82 +++++++++++++ .../general/invoice-inbox/manifest.json | 2 +- .../__tests__/extension-gating.test.ts | 24 ++++ .../__tests__/has-capability.test.ts | 24 ++++ lib/entitlements/has-capability.ts | 9 +- lib/entitlements/keys.ts | 24 ++++ .../_generated/sector-definitions.ts | 2 +- lib/extensions/sectors.ts | 13 +++ lib/worklist/__tests__/visible-total.test.ts | 27 +++++ lib/worklist/index.ts | 1 + lib/worklist/visible-total.ts | 39 +++++++ messages/en.json | 5 +- messages/sv.json | 5 +- 20 files changed, 480 insertions(+), 25 deletions(-) create mode 100644 extensions/general/enable-banking/__tests__/capability-gate.test.ts create mode 100644 lib/entitlements/__tests__/extension-gating.test.ts create mode 100644 lib/worklist/__tests__/visible-total.test.ts create mode 100644 lib/worklist/visible-total.ts diff --git a/app/(dashboard)/e/[sector]/[slug]/page.tsx b/app/(dashboard)/e/[sector]/[slug]/page.tsx index 1d035584..1e260015 100644 --- a/app/(dashboard)/e/[sector]/[slug]/page.tsx +++ b/app/(dashboard)/e/[sector]/[slug]/page.tsx @@ -1,7 +1,15 @@ import { createClient } from '@/lib/supabase/server' import { redirect, notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import Link from 'next/link' import { getExtensionDefinition } from '@/lib/extensions/sectors' import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader' +import { getActiveCompanyId } from '@/lib/company/context' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { requiredCapabilityForExtension } from '@/lib/entitlements/keys' +import { resolveIcon } from '@/lib/extensions/icon-resolver' +import { EmptyState } from '@/components/ui/empty-state' +import { Button } from '@/components/ui/button' export default async function ExtensionWorkspacePage({ params, @@ -17,6 +25,34 @@ export default async function ExtensionWorkspacePage({ const definition = getExtensionDefinition(sector, slug) if (!definition) notFound() + // Paywall: an extension whose entire value is a paid service (invoice-inbox → + // AI field extraction) is blocked at the page, not just at its API routes, so + // a non-payer never lands on a working-looking workspace. The sidebar item and + // command palette hide the same way off the same map (lib/entitlements/keys). + // Fail closed: no resolvable company or the capability absent, both block. + const requiredCapability = requiredCapabilityForExtension(sector, slug) + if (requiredCapability) { + const companyId = await getActiveCompanyId(supabase, user.id) + const allowed = companyId + ? await hasCapability(supabase, companyId, requiredCapability) + : false + if (!allowed) { + const t = await getTranslations('extensions') + const Icon = resolveIcon(definition.icon) + return ( + + + + + + ) + } + } + return ( ({ shouldEnforceMfa: vi.fn(() => false), })) +// Drive the paywall gate directly. Keep the module's real exports (the resolver +// path reads keys.ts, not this module) and only stub requireCapability so a test +// can force "blocked" / "allowed" without seeding capability_grants rows. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => ({ + ...(await importOriginal()), + requireCapability: vi.fn(), +})) + +// Control which capability an extension requires directly, instead of depending +// on the generated extension registry: it is empty in the zero-extensions +// (core-only) build, which would otherwise make the gate a no-op here. +vi.mock('@/lib/extensions/sectors', async (importOriginal) => ({ + ...(await importOriginal()), + requiredCapabilityForExtensionId: vi.fn(), +})) + import { createClient } from '@/lib/supabase/server' import { shouldEnforceMfa } from '@/lib/auth/mfa' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { requiredCapabilityForExtensionId } from '@/lib/extensions/sectors' import { extensionRegistry } from '@/lib/extensions/registry' import { GET, POST } from '../route' const mockCreateClient = vi.mocked(createClient) const mockShouldEnforceMfa = vi.mocked(shouldEnforceMfa) +const mockRequireCapability = vi.mocked(requireCapability) +const mockRequiredCapabilityForExtensionId = vi.mocked(requiredCapabilityForExtensionId) function createPathParams(path: string[]) { return { params: Promise.resolve({ path }) } @@ -51,6 +71,11 @@ describe('Extension Catch-All Route', () => { // clearAllMocks doesn't reset implementations: re-assert the default so the // AAL2 test's mockReturnValue(true) can't leak into later cases. mockShouldEnforceMfa.mockReturnValue(false) + // Default: capability present (allowed). Gated-extension tests override this. + mockRequireCapability.mockResolvedValue(null) + // Default: extension requires no capability, so the gate is a no-op and + // existing dispatch tests behave as before. Paywall tests override this. + mockRequiredCapabilityForExtensionId.mockReturnValue(undefined) extensionRegistry.clear() }) @@ -215,4 +240,89 @@ describe('Extension Catch-All Route', () => { expect(status).toBe(200) expect(handler).toHaveBeenCalled() }) + + // Paywall: the dispatcher gates every company-context route of an extension + // that declares a required capability (invoice-inbox → ai). The resolver is + // mocked so the test is deterministic in any build; requireCapability is + // mocked to force the outcome. + it('blocks a paid extension route when the company lacks the capability, before the handler runs', async () => { + const handler = vi.fn() + extensionRegistry.register({ + id: 'invoice-inbox', + name: 'Dokumentinkorg', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/items', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockRequiredCapabilityForExtensionId.mockReturnValue('ai') + mockRequireCapability.mockResolvedValue( + NextResponse.json( + { error: 'paid', capability_blocked: true, capability: 'ai' }, + { status: 403 }, + ), + ) + + const request = createMockRequest('/api/extensions/ext/invoice-inbox/items') + const response = await GET(request, createPathParams(['invoice-inbox', 'items'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(handler).not.toHaveBeenCalled() + expect(mockRequireCapability).toHaveBeenCalledWith(expect.anything(), 'company-1', 'ai') + }) + + it('dispatches a paid extension route when the company holds the capability', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ data: [] })) + extensionRegistry.register({ + id: 'invoice-inbox', + name: 'Dokumentinkorg', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/items', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockRequiredCapabilityForExtensionId.mockReturnValue('ai') + mockRequireCapability.mockResolvedValue(null) // capability present + + const request = createMockRequest('/api/extensions/ext/invoice-inbox/items') + const response = await GET(request, createPathParams(['invoice-inbox', 'items'])) + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + }) + + it('never gates the skipAuth ingestion webhook of a paid extension (freeze-and-retain)', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })) + extensionRegistry.register({ + id: 'invoice-inbox', + name: 'Dokumentinkorg', + version: '1.0.0', + apiRoutes: [{ method: 'POST', path: '/inbound', handler, skipAuth: true }], + }) + // Even if the gate would block, the skipAuth branch returns first. + mockRequireCapability.mockResolvedValue( + NextResponse.json({ capability_blocked: true }, { status: 403 }), + ) + + const request = createMockRequest('/api/extensions/ext/invoice-inbox/inbound', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createPathParams(['invoice-inbox', 'inbound'])) + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + expect(mockRequireCapability).not.toHaveBeenCalled() + }) }) diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index f1f01e40..b65717fd 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -4,6 +4,8 @@ import { ensureInitialized } from '@/lib/init' import { extensionRegistry } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { requireCompanyId } from '@/lib/company/context' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { requiredCapabilityForExtensionId } from '@/lib/extensions/sectors' import { createLogger } from '@/lib/logger' import type { ApiRouteDefinition } from '@/lib/extensions/types' @@ -276,6 +278,23 @@ async function handleRequest( const companyId = await requireCompanyId(supabase, user.id) + // Paywall: this dispatcher is the single chokepoint for the whole enabled- + // extension API surface (same reasoning as the MFA gate above), so an + // extension whose workspace is a paid service (EXTENSION_REQUIRED_CAPABILITY, + // e.g. the AI-only invoice-inbox) gates EVERY one of its company-context routes + // here, not just its AI-extraction steps. Mirrors the sidebar/page gate off the + // same map. The skipAuth ingestion webhook (/inbound) returned above is exempt + // by construction: a lapsed company's inbound documents must still be stored + // (freeze-and-retain), and booked documents stay reachable via the verifikat. + const requiredCapability = requiredCapabilityForExtensionId(extensionId) + if (requiredCapability) { + const blocked = await requireCapability(supabase, companyId, requiredCapability) + if (blocked) { + log.info('extension call blocked: capability required', { capability: requiredCapability }) + return decorateResponse(blocked, requestId) + } + } + // Build context and dispatch const ctx = createExtensionContext(supabase, user.id, companyId, extensionId, requestId) const response = await matchedRoute.handler(handlerRequest, ctx) diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index c98cc422..6897d25f 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -25,6 +25,8 @@ import { type LucideIcon, } from 'lucide-react' import { cn } from '@/lib/utils' +import { useCompany } from '@/contexts/CompanyContext' +import { requiredCapabilityForExtension } from '@/lib/entitlements/keys' type Entry = { id: string @@ -79,6 +81,19 @@ export default function CommandPalette() { const [query, setQuery] = useState('') const [activeIndex, setActiveIndex] = useState(0) const inputRef = useRef(null) + const { capabilities } = useCompany() + + // Drop entries that jump to a paywalled extension workspace the active + // company can't reach (e.g. the AI-only Dokumentinkorg). The page itself is + // gated server-side; this keeps ⌘K from offering a dead destination. + const allowedByCapability = useMemo(() => { + return (entry: Entry) => { + const m = entry.href.match(/^\/e\/([^/]+)\/([^/?#]+)/) + if (!m) return true + const required = requiredCapabilityForExtension(m[1], m[2]) + return !required || capabilities.includes(required) + } + }, [capabilities]) function handleOpenChange(next: boolean) { setOpen(next) @@ -108,14 +123,14 @@ export default function CommandPalette() { const q = query.trim().toLowerCase() - const filteredActions = useMemo( - () => (q ? ACTION_ENTRIES.filter(e => matches(e, q)) : ACTION_ENTRIES), - [q], - ) - const filteredPages = useMemo( - () => (q ? PAGE_ENTRIES.filter(e => matches(e, q)) : PAGE_ENTRIES.slice(0, 6)), - [q], - ) + const filteredActions = useMemo(() => { + const visible = ACTION_ENTRIES.filter(allowedByCapability) + return q ? visible.filter(e => matches(e, q)) : visible + }, [q, allowedByCapability]) + const filteredPages = useMemo(() => { + const visible = PAGE_ENTRIES.filter(allowedByCapability) + return q ? visible.filter(e => matches(e, q)) : visible.slice(0, 6) + }, [q, allowedByCapability]) const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0 ? { diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 4b5284b9..5935de08 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -10,6 +10,9 @@ import { EmptyState } from '@/components/ui/empty-state' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { visibleWorklistTotal } from '@/lib/worklist/visible-total' import { ArrowLeftRight, ArrowRight, @@ -96,6 +99,10 @@ export default function AttGoraSection({ }: AttGoraSectionProps) { const t = useTranslations('dashboard') const { toast } = useToast() + // The Dokumentinkorg is a paid (AI) surface: a non-payer's home to-do list + // must not offer a row that jumps to the gated workspace. Mirrors the sidebar + // + command palette gate; the page itself enforces it server-side. + const hasAi = useCapability(CAPABILITY.ai) const [counts, setCounts] = useState(worklist.counts) const [total, setTotal] = useState(worklist.total) @@ -166,7 +173,8 @@ export default function AttGoraSection({ } } - const bokforRows = counts.book_transaction > 0 || counts.inbox_document > 0 || matches.length > 0 + const showInboxDocuments = hasAi && counts.inbox_document > 0 + const bokforRows = counts.book_transaction > 0 || showInboxDocuments || matches.length > 0 const granskaRows = counts.supplier_invoice_approval > 0 || counts.verifikat_missing_document > 0 || @@ -177,10 +185,16 @@ export default function AttGoraSection({ expiringBankConnections.length > 0 const allClear = !bokforRows && !granskaRows && !bevakaRows - // The header total must equal what the section actually shows: the worklist - // total plus expiring bank connections, which are dashboard-only (not a - // lib/worklist category). Every count that feeds this number has a row. - const displayTotal = total + expiringBankConnections.length + // The header total must equal what the section actually shows, computed off + // the same visibleWorklistTotal helper as the dashboard KPI tile so the two + // can never drift: the hidden paid inbox row is subtracted for non-payers, + // else the header would count work the section no longer renders. + const displayTotal = visibleWorklistTotal({ + total, + inboxDocumentCount: counts.inbox_document, + hasAi, + extra: expiringBankConnections.length, + }) return (
@@ -289,7 +303,7 @@ export default function AttGoraSection({ )} - {counts.inbox_document > 0 && ( + {showInboxDocuments && ( `erp_setup_fresh_start:${companyId}` @@ -109,10 +110,14 @@ export default function DashboardContent({ companyId, summary, worklist, suggest }).format(amount) } - // One number, one source: the worklist total plus expiring bank connections - // (dashboard-only, not a lib/worklist category). Must match AttGoraSection's - // header so the tile and the section never disagree. - const todoCount = worklist.total + (summary.expiringBankConnections?.length ?? 0) + // One number, one source (visibleWorklistTotal): the worklist total plus + // expiring bank connections (dashboard-only), minus the hidden paid inbox row + // for non-payers. Must match AttGoraSection's header off the same helper. + const todoCount = visibleWorklistTotalFrom( + worklist, + hasAi, + summary.expiringBankConnections?.length ?? 0, + ) return (
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