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:
co-authored by
Claude Fable 5
parent
a566a42aec
commit
19cbb0094b
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './types'
|
||||
export * from './categories'
|
||||
export * from './aggregate'
|
||||
export * from './visible-total'
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user