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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-07 23:20:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a566a42aec
commit 19cbb0094b
20 changed files with 480 additions and 25 deletions
@@ -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 (
<EmptyState
icon={Icon}
title={t('upsell_title', { name: definition.name })}
description={t('upsell_description')}
>
<Link href="/settings/billing">
<Button>{t('upsell_cta')}</Button>
</Link>
</EmptyState>
)
}
}
return (
<ExtensionWorkspaceLoader
sector={sector}
@@ -33,13 +33,33 @@ vi.mock('@/lib/auth/mfa', () => ({
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<typeof import('@/lib/entitlements/has-capability')>()),
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<typeof import('@/lib/extensions/sectors')>()),
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()
})
})
+19
View File
@@ -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)
+23 -8
View File
@@ -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<HTMLInputElement>(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
? {
+20 -6
View File
@@ -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 (
<section aria-label={t('att_gora_title')}>
@@ -289,7 +303,7 @@ export default function AttGoraSection({
</div>
</div>
)}
{counts.inbox_document > 0 && (
{showInboxDocuments && (
<WorklistRow
href="/e/general/invoice-inbox"
icon={Inbox}
+9 -4
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'
import type { Deadline, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
const setupFreshStartKey = (companyId: string) => `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 (
<div className="stagger-enter space-y-8">
+11 -2
View File
@@ -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 /).
@@ -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<typeof import('@/lib/entitlements/has-capability')>()
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)
},
)
})
@@ -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."
}
}
@@ -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)
})
})
@@ -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 } },
+8 -1
View File
@@ -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'
)
+24
View File
@@ -89,3 +89,27 @@ export const PAID_OPERATION_CAPABILITY_MAP: Readonly<Partial<Record<string, Capa
submit_vat_declaration: CAPABILITY.skatteverket,
submit_agi: CAPABILITY.skatteverket,
} as const
/**
* Extension workspace → required capability, keyed by `sector/slug`. This is the
* page/nav twin of the API-route gates: an extension whose entire value is a
* paid service should not just 403 its writes but be hidden from the sidebar and
* blocked at the page so a non-payer never lands on a dead workspace.
*
* invoice-inbox is fully gated on `ai`: its reason to exist is the AI field
* extraction (extractInvoiceFields / gnubok_upload_document), already the paid
* chokepoint on every other surface (HTTP upload/attach/retry, the MCP tool).
* Both the sidebar item and the /e/[sector]/[slug] page read this map so the two
* surfaces can never drift apart.
*/
export const EXTENSION_REQUIRED_CAPABILITY: Readonly<Partial<Record<string, CapabilityKey>>> = {
'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}`]
}
@@ -98,7 +98,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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"
+13
View File
@@ -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)
}
@@ -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
})
})
+1
View File
@@ -1,3 +1,4 @@
export * from './types'
export * from './categories'
export * from './aggregate'
export * from './visible-total'
+39
View File
@@ -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,
})
}
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",