diff --git a/DECISIONS.md b/DECISIONS.md index 6025d5b0..d9a1426c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -21,3 +21,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass. [2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time. [2026-07-06] Kept two-step potential-match fetch on /transactions instead of single PostgREST embed: prod schema cache has no FK relationship for transactions.potential_supplier_invoice_id (PGRST200; migration 20260225100248 ADD COLUMN IF NOT EXISTS likely skipped the REFERENCES clause because the column pre-existed). Revisit after adding the FK via a new migration. +[2026-07-06] Bolagsverket testbänk E2E as skipped-by-default vitest (BOLAGSVERKET_TESTBANK_E2E=1): needs the IP-bound firewall opening, so it can never run in CI; GUIDE's documented test pnr 190001010106 fails Luhn, 190001010107 is the accepted one. +[2026-07-06] Paywall leak sweep gating choices: SKV unlock (DELETE /declaration/lock) left ungated so a lapsed company can recover a draft it locked while entitled; agi/kontrollera HU/IU gated (direct SKV API interaction = paid, file download stays free); recurring auto-send blocks only the email, invoice creation stays free (freeze-and-retain). diff --git a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts index dc5aeadd..8ff327ee 100644 --- a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts @@ -18,6 +18,9 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) vi.mock('@/lib/email/service', () => ({ getEmailService: vi.fn() })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) vi.mock('@/lib/branding/service', () => ({ getBranding: () => ({ appUrl: 'https://app.example.test' }), })) @@ -80,6 +83,20 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => { expect(response.status).toBe(401) }) + it('returns 403 when the company lacks the email_send capability', async () => { + const { requireCapability } = await import('@/lib/entitlements/has-capability') + vi.mocked(requireCapability).mockResolvedValueOnce( + NextResponse.json({ capability_blocked: true }, { status: 403 }), + ) + const { supabase } = createQueuedMockSupabase() + authed(supabase) + mockEmail({ success: true }) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + expect(response.status).toBe(403) + }) + it('returns 404 when the run does not exist', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() authed(supabase) diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index 0ab15c16..f7ef0eb7 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -6,6 +6,8 @@ import { getEmailService } from '@/lib/email/service' import { getBranding } from '@/lib/branding/service' import { rotateLinkForEmployee } from '@/lib/salary/payslips/links' import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' ensureInitialized() @@ -22,6 +24,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'salary_run.payslips_send', async (_request, { supabase, companyId, user, log, requestId }, { params }) => { const { id } = await params + + const blocked = await requireCapability(supabase, companyId, CAPABILITY.email_send) + if (blocked) return blocked + const emailService = getEmailService() const { data: run } = await supabase diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index 83db2620..87a72388 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -14,6 +14,10 @@ vi.mock('../lib/sync', () => ({ syncAccountTransactions: vi.fn(), })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + import { isSessionExpiredResponse, SessionExpiredError, diff --git a/extensions/general/enable-banking/__tests__/sync-filter.test.ts b/extensions/general/enable-banking/__tests__/sync-filter.test.ts index 293da660..788e88f7 100644 --- a/extensions/general/enable-banking/__tests__/sync-filter.test.ts +++ b/extensions/general/enable-banking/__tests__/sync-filter.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + import { enableBankingExtension } from '../index' import type { ExtensionContext } from '@/lib/extensions/types' import type { StoredAccount } from '../types' diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index b1c36b91..baba10e8 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -15,6 +15,8 @@ import { DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, } from '@/lib/reconciliation/bank-reconciliation' import { checkRateLimit } from '@/lib/auth/rate-limit-http' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { StoredAccount } from './types' import type { Transaction } from '@/types' @@ -107,6 +109,9 @@ export const enableBankingExtension: Extension = { } const companyId = ctx.companyId + const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync) + if (blocked) return blocked + const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId } = await request.json() // Reconnect mode: re-authorize an EXISTING connection in place (no @@ -410,6 +415,9 @@ export const enableBankingExtension: Extension = { } const companyId = ctx.companyId + const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync) + if (blocked) return blocked + const rl = await checkRateLimit({ prefix: 'enable-banking:sync', identifier: user.id, diff --git a/extensions/general/skatteverket/__tests__/capability-gate.test.ts b/extensions/general/skatteverket/__tests__/capability-gate.test.ts new file mode 100644 index 00000000..058b229c --- /dev/null +++ b/extensions/general/skatteverket/__tests__/capability-gate.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Force the gate itself to run (no dev bypass), but stub the resolver so we +// control entitlement per test. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + requireCapability: vi.fn(), + } +}) + +import { skatteverketExtension } from '../index' +import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' +import type { ExtensionContext } from '@/lib/extensions/types' + +const GATED: Array<{ method: string; path: string }> = [ + { method: 'GET', path: '/authorize' }, + { method: 'POST', path: '/declaration/validate' }, + { method: 'POST', path: '/declaration/draft' }, + { method: 'PUT', path: '/declaration/lock' }, + { method: 'POST', path: '/agi/submit' }, + { method: 'POST', path: '/agi/spara' }, + { method: 'POST', path: '/agi/las' }, + { method: 'POST', path: '/agi/kontrollera/hu' }, + { method: 'POST', path: '/agi/kontrollera/iu' }, + { method: 'POST', path: '/skattekonto/sync' }, +] + +function makeContext(): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'skatteverket', + requestId: 'req_test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: vi.fn() } 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('skatteverket 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.skatteverket), + ) + const route = skatteverketExtension.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/skatteverket${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'GET' ? undefined : 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.skatteverket) + }) + + // Unlock operations stay free: a lapsed company must be able to unlock + // what it locked while entitled (draft recovery, never data hostage). + it.each([ + { method: 'DELETE', path: '/declaration/lock' }, + { method: 'POST', path: '/agi/lasUpp' }, + ])('$method $path (unlock) is NOT paywall-gated', async ({ method, path }) => { + vi.mocked(requireCapability).mockResolvedValue( + capabilityBlockedResponse(CAPABILITY.skatteverket), + ) + const route = skatteverketExtension.apiRoutes?.find( + (r) => r.method === method && r.path === path, + ) + expect(route).toBeDefined() + + const request = new Request( + `https://test.local/api/extensions/ext/skatteverket${path}?period=202606`, + { method }, + ) + const response = await route!.handler(request, makeContext()) + // Fails later (missing params / no tokens) but never with the paywall 403. + if (response.status === 403) { + const body = (await response.json()) as { capability_blocked?: boolean } + expect(body.capability_blocked).not.toBe(true) + } + expect(vi.mocked(requireCapability)).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index fb286af7..73b2831e 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -8,6 +8,8 @@ import { AGI_KONTROLLERA_MAX_BYTES, } from '@/lib/salary/agi/kontrollera-schemas' import { TimeoutError } from '@/lib/http/fetch-with-timeout' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth' import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store' import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client' @@ -109,6 +111,18 @@ import type { VatPeriodType } from '@/types' const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member']) +/** + * Paywall gate for routes that talk to Skatteverket's API. The declaration + * FILE download is always free (manual filing is never blocked); the direct + * API interaction (connect, validate, draft, lock, submit, sync) is the paid + * convenience. Returns null when entitled, a 403 capability_blocked response + * otherwise. Unlock (DELETE /declaration/lock) is deliberately NOT gated so a + * lapsed company can always recover a draft it locked while entitled. + */ +async function requireSkvCapability(ctx: ExtensionContext): Promise { + return requireCapability(ctx.supabase, ctx.companyId, CAPABILITY.skatteverket) +} + /** * Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx * presence alone (set by middleware) only confirms the user is signed in @@ -161,6 +175,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const state = crypto.randomUUID() const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' @@ -446,6 +462,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod, momsuppgift } = @@ -492,6 +510,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod, momsuppgift } = @@ -625,6 +645,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) @@ -811,6 +833,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx) @@ -934,6 +958,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const body = (await request.json()) as { inlamningId?: number; salaryRunId?: string } const inlamningId = Number(body.inlamningId) @@ -1335,6 +1361,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const rbac = await requireAgiWriteRole(ctx) if (rbac) return rbac @@ -1444,6 +1472,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const rbac = await requireAgiWriteRole(ctx) if (rbac) return rbac @@ -1551,6 +1581,8 @@ export const skatteverketExtension: Extension = { path: '/agi/las', handler: async (request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const url = new URL(request.url) const arbetsgivare = url.searchParams.get('arbetsgivare') @@ -1727,6 +1759,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const result = await syncSkattekonto(ctx) return NextResponse.json({ data: result }) diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index 923bf867..507f089a 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -21,6 +21,8 @@ import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { generateInvoiceEmailHtml, generateInvoiceEmailText, @@ -361,6 +363,16 @@ async function sendInvoiceFromSchedule( }) return false } + // Paywall: email sending is a paid capability. The invoice itself is still + // created (bookkeeping stays free); it just isn't emailed, and the schedule + // surfaces the standard manual-send warning (freeze-and-retain). + if (!(await hasCapability(supabase, companyId, CAPABILITY.email_send))) { + log.warn('company lacks email_send capability; recurring schedule cannot auto-send', { + invoiceId: invoice.id, + companyId, + }) + return false + } if (!invoice.customer.email) { log.warn('customer has no email; recurring schedule cannot auto-send', { invoiceId: invoice.id,