diff --git a/DECISIONS.md b/DECISIONS.md index 70e107cd..150ef327 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1154,3 +1154,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-21] Peppol receiving (PR2) keeps Qvalia's consolidated partner account: every company's 0007:orgnr is registered on OUR account (PUT /partner/{p}/account/{p}/peppol/{id}) and inbound documents are routed by the AccountingCustomerParty EndpointID through peppol_registrations, because Qvalia confirmed sending needs no per-company account and child accounts would only add a 100 kr/mån tenant fee per customer; the exact received UBL XML is archived as a WORM document (upload_source e_invoice, extractionOwner none) and the inbox row is filled from the structured UBL with confidence 1 (no model pass), following the mail-hunt precedent of core inserting invoice_inbox_items directly; personnummer-based companies are refused registration until 0088 GLN support exists (publishing them would put personal data in the Peppol Directory); the poll is a 10-minute cron (GET .../readinvoices marks documents read at Qvalia, so every fetched document is archived before anything else can fail). [2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math. [2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing. +[2026-08-21] Peppol access is granted per company by the operators, never self-served (peppol_access table, locked by default): every transmission is billed per document by Qvalia and every receiving identifier consumes a contracted tenant slot, so the company asks from Settings > Fakturering (request row + e-mail to support) and we enable it with scripts/peppol/access.ts, setting max_sends (null = no cap) and separately receive_enabled; the send route refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice, and registration refuses PEPPOL_RECEIVING_NOT_ENABLED. Founder call 2026-08-21 after the first open-for-all hour in prod. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 82ac17e2..67cfe6e5 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -227,6 +227,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // Whether this deployment has a contracted Access Point switched on; the // menu item stays a truthful "provider required" note otherwise. const [peppolTransportAvailable, setPeppolTransportAvailable] = useState(false) + // Per-company grant from the operators; without it the send item explains + // how to ask instead of pretending to work. + const [peppolAccess, setPeppolAccess] = useState<{ + send_enabled: boolean + max_sends: number | null + sent_count: number + remaining_sends: number | null + } | null>(null) const [peppolDeliveries, setPeppolDeliveries] = useState([]) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -910,11 +918,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const payload = (await response.json()) as { data?: PeppolDeliveryView[] transport?: { available?: boolean } + access?: { send_enabled: boolean; max_sends: number | null; sent_count: number; remaining_sends: number | null } } const rows = Array.isArray(payload.data) ? [...payload.data] : [] rows.sort((a, b) => (a.status_at < b.status_at ? 1 : a.status_at > b.status_at ? -1 : 0)) setPeppolDeliveries(rows) setPeppolTransportAvailable(payload.transport?.available === true) + setPeppolAccess(payload.access ?? null) } catch { // Peppol status is supplementary; the page stays usable without it. } @@ -1334,7 +1344,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // assigns the number server-side, so the menu shows once a provider is on. const showPeppolActions = !isSelfBilled && isRealInvoice && !isCreditNote && (!!invoice.invoice_number || peppolTransportAvailable) - const canSendPeppol = peppolTransportAvailable && PEPPOL_SENDABLE_STATUSES.has(invoice.status) + const peppolSendGranted = !!peppolAccess?.send_enabled + const peppolSendsLeft = peppolAccess?.remaining_sends === null || peppolAccess?.remaining_sends === undefined + ? true + : peppolAccess.remaining_sends > 0 + const canSendPeppol = peppolTransportAvailable && peppolSendGranted && peppolSendsLeft + && PEPPOL_SENDABLE_STATUSES.has(invoice.status) const peppolRecipientLabel = invoice.customer?.org_number ? `0007:${invoice.customer.org_number.replace(/\D/g, '')}` : '0007' @@ -1604,7 +1619,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('prepare_peppol_delivery')} - {peppolTransportAvailable ? ( + {peppolTransportAvailable && peppolSendGranted && peppolSendsLeft ? ( setShowPeppolSendDialog(true)} disabled={isSendingPeppol || !canWrite || !canSendPeppol} @@ -1612,6 +1627,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('send_via_peppol')} + ) : peppolTransportAvailable ? ( + + + + {t('send_via_peppol')} + + {peppolSendGranted ? t('peppol_send_limit_reached') : t('peppol_access_required')} + + + ) : ( diff --git a/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts index f1e55211..e2be57ec 100644 --- a/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts +++ b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts @@ -13,6 +13,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +const serviceTables = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceTables.supabase, +})) + vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) diff --git a/app/api/invoices/[id]/peppol/deliveries/route.ts b/app/api/invoices/[id]/peppol/deliveries/route.ts index 0d4f5cdb..4450f6b0 100644 --- a/app/api/invoices/[id]/peppol/deliveries/route.ts +++ b/app/api/invoices/[id]/peppol/deliveries/route.ts @@ -4,8 +4,10 @@ import { privateNoStore } from '@/lib/api/private-no-store' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { ensureInitialized } from '@/lib/init' +import { getPeppolAccessSummary } from '@/lib/invoices/peppol-access' import { listPeppolDeliverySummaries } from '@/lib/invoices/peppol-delivery' import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport' +import { createServiceClient } from '@/lib/supabase/server' ensureInitialized() @@ -39,9 +41,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( companyId, invoiceId, }) + const access = await getPeppolAccessSummary({ supabase, service: createServiceClient(), companyId }) return privateNoStore(NextResponse.json({ data: deliveries, transport: getPeppolTransportAvailability(), + access, })) } catch (err) { return privateNoStore(errorResponse(err, log, { requestId })) diff --git a/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts index 49152ca9..54c942c5 100644 --- a/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/peppol/send/__tests__/route.test.ts @@ -16,6 +16,7 @@ import { } from '@/lib/invoices/peppol-transport' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const serviceTables = createQueuedMockSupabase() const requireAuthMock = vi.fn() const serviceRpcMock = vi.fn() const issueAndBookMock = vi.fn() @@ -37,7 +38,10 @@ vi.mock('@/lib/auth/require-write', () => ({ })) vi.mock('@/lib/supabase/server', () => ({ - createServiceClient: () => ({ rpc: (...args: unknown[]) => serviceRpcMock(...args) }), + createServiceClient: () => ({ + from: (...args: unknown[]) => serviceTables.supabase.from(...(args as [string])), + rpc: (...args: unknown[]) => serviceRpcMock(...args), + }), })) vi.mock('@/lib/invoices/issue-and-book-invoice', () => ({ @@ -135,6 +139,21 @@ function makeTransport(overrides: Partial = {}): PeppolTranspor } } +const accessRow = { + company_id: 'company-1', + status: 'enabled', + max_sends: 50, + receive_enabled: false, + requested_at: null, requested_by: null, request_note: null, + enabled_at: '2026-08-21T16:00:00.000Z', enabled_by: 'jakob', disabled_at: null, note: null, + created_at: '2026-08-21T16:00:00.000Z', updated_at: '2026-08-21T16:00:00.000Z', +} +/** Peppol access is per company: grant it (service reads access row, then the send count). */ +function grantAccess(maxSends: number | null = 50, sent = 0) { + serviceTables.enqueue({ data: { ...accessRow, max_sends: maxSends }, error: null }) + serviceTables.enqueue({ data: null, error: null, count: sent }) +} + /** The service-role RPC echoes the event's status back as the projection. */ function serviceRpcEcho() { serviceRpcMock.mockImplementation(async (_fn: string, args: Record) => ({ @@ -157,6 +176,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { beforeEach(() => { vi.clearAllMocks() reset() + serviceTables.reset() serviceRpcEcho() process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia' process.env.QVALIA_PARTNER_REG_NO = 'SE5560000000' @@ -208,8 +228,31 @@ describe('POST /api/invoices/[id]/peppol/send', () => { expect(body.error.details.reason).toBe('provider_selection_required') }) + it('refuses a company without a Peppol grant before touching the invoice', async () => { + const transport = makeTransport() + unregister = registerPeppolTransport(transport) + serviceTables.enqueue({ data: null, error: null }) // no access row + const response = await send() + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('PEPPOL_ACCESS_REQUIRED') + expect(transport.submit).not.toHaveBeenCalled() + }) + + it('refuses once the company has used its sending cap', async () => { + const transport = makeTransport() + unregister = registerPeppolTransport(transport) + grantAccess(5, 5) + const response = await send() + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error.code).toBe('PEPPOL_SEND_LIMIT_REACHED') + expect(body.error.details).toMatchObject({ max_sends: 5, sent_count: 5 }) + expect(transport.submit).not.toHaveBeenCalled() + }) + it('returns 404 when the invoice is not in the active company', async () => { unregister = registerPeppolTransport(makeTransport()) + grantAccess() enqueue({ data: null, error: { message: 'not found' } }) const response = await send() expect(response.status).toBe(404) @@ -218,6 +261,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('rejects cancelled and proforma invoices with a state conflict', async () => { unregister = registerPeppolTransport(makeTransport()) + grantAccess() enqueue({ data: invoiceRow({ status: 'cancelled' }), error: null }) enqueue({ data: company, error: null }) const response = await send() @@ -235,6 +279,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { }), }) unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow(), error: null }) enqueue({ data: company, error: null }) enqueue({ data: stagedDelivery, error: null }) @@ -252,6 +297,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('looks up, submits the staged XML and records the lifecycle for an already issued invoice', async () => { const transport = makeTransport() unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow(), error: null }) enqueue({ data: company, error: null }) enqueue({ data: stagedDelivery, error: null }) @@ -294,6 +340,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('issues and books a draft only after the network accepted it', async () => { const transport = makeTransport() unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow({ status: 'draft' }), error: null }) enqueue({ data: company, error: null }) enqueue({ data: stagedDelivery, error: null }) @@ -315,6 +362,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('reports a failed issuance without pretending the network send did not happen', async () => { unregister = registerPeppolTransport(makeTransport()) + grantAccess() issueAndBookMock.mockResolvedValue({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' }) enqueue({ data: invoiceRow({ status: 'draft' }), error: null }) enqueue({ data: company, error: null }) @@ -334,6 +382,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('replays idempotently when the exact XML was already handed to the network', async () => { const transport = makeTransport() unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow(), error: null }) enqueue({ data: company, error: null }) enqueue({ @@ -360,6 +409,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { ), }) unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow({ status: 'draft' }), error: null }) enqueue({ data: company, error: null }) enqueue({ data: stagedDelivery, error: null }) @@ -386,6 +436,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { ), }) unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow(), error: null }) enqueue({ data: company, error: null }) enqueue({ data: stagedDelivery, error: null }) @@ -405,6 +456,7 @@ describe('POST /api/invoices/[id]/peppol/send', () => { it('refuses to resend an exact document the access point already rejected', async () => { const transport = makeTransport() unregister = registerPeppolTransport(transport) + grantAccess() enqueue({ data: invoiceRow(), error: null }) enqueue({ data: company, error: null }) enqueue({ diff --git a/app/api/invoices/[id]/peppol/send/route.ts b/app/api/invoices/[id]/peppol/send/route.ts index bafb8719..1030960f 100644 --- a/app/api/invoices/[id]/peppol/send/route.ts +++ b/app/api/invoices/[id]/peppol/send/route.ts @@ -17,6 +17,7 @@ import { stagePeppolDelivery, type PeppolDeliverySummary, } from '@/lib/invoices/peppol-delivery' +import { checkPeppolSendPermission } from '@/lib/invoices/peppol-access' import { generatePeppolDocumentOrResponse, loadPeppolRecords } from '@/lib/invoices/peppol-document' import { getPeppolTransport, @@ -112,6 +113,21 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( })) } + // Access is granted per company by the operators and capped in sends: + // refuse before any invoice data is touched. + const service = createServiceClient() + const permission = await checkPeppolSendPermission({ service, companyId }) + if (!permission.ok) { + return privateNoStore(errorResponseFromCode(permission.code, log, { + requestId, + details: { + access_status: permission.summary.status, + max_sends: permission.summary.max_sends, + sent_count: permission.summary.sent_count, + }, + })) + } + const records = await loadPeppolRecords({ supabase, companyId, invoiceId, log, requestId }) if (!records.ok) return records.response const { invoice, company } = records @@ -153,7 +169,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( if (!generated.ok) return generated.response const document = generated.document - const service = createServiceClient() const provider = transport.provider // Consolidated Qvalia setup: one provider account for every company. The // adapter resolves the account; the lifecycle only needs a stable label. diff --git a/app/api/settings/peppol/__tests__/route.test.ts b/app/api/settings/peppol/__tests__/route.test.ts index a82b9489..51ea99ab 100644 --- a/app/api/settings/peppol/__tests__/route.test.ts +++ b/app/api/settings/peppol/__tests__/route.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NextResponse } from 'next/server' -import { createMockRequest, createQueuedMockSupabase } from '@/tests/helpers' +import { createMockRequest, createMockRouteParams, createQueuedMockSupabase } from '@/tests/helpers' import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() @@ -24,6 +24,13 @@ vi.mock('@/lib/supabase/server', () => ({ import { DELETE, GET, POST } from '../route' const user = { id: 'user-1', email: 'owner@example.test' } +const enabledAccess = { + company_id: 'company-1', status: 'enabled', max_sends: 50, receive_enabled: true, + requested_at: null, requested_by: null, request_note: null, + enabled_at: '2026-08-21T16:00:00.000Z', enabled_by: 'jakob', disabled_at: null, note: null, + created_at: '2026-08-21T16:00:00.000Z', updated_at: '2026-08-21T16:00:00.000Z', +} + const registeredRow = { id: 'reg-1', company_id: 'company-1', @@ -83,18 +90,20 @@ describe('/api/settings/peppol', () => { supabase: mockSupabase, error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }) - const response = await GET(createMockRequest('/api/settings/peppol')) + const response = await GET(createMockRequest('/api/settings/peppol'), createMockRouteParams({})) expect(response.status).toBe(401) }) it('GET tells the truth when no access point is switched on', async () => { delete process.env.PEPPOL_TRANSPORT_PROVIDER - const response = await GET(createMockRequest('/api/settings/peppol')) + enqueue({ data: null, error: null }) // access row (none) + const response = await GET(createMockRequest('/api/settings/peppol'), createMockRouteParams({})) const body = await response.json() expect(response.status).toBe(200) expect(body.data).toMatchObject({ transport: { available: false }, receiving_supported: false, + access: { status: 'none', send_enabled: false }, registration: null, }) }) @@ -102,36 +111,58 @@ describe('/api/settings/peppol', () => { it('GET returns the live registration when the adapter supports receiving', async () => { unregister = registerPeppolTransport(makeTransport()) enqueue({ data: [registeredRow], error: null }) - const response = await GET(createMockRequest('/api/settings/peppol')) + enqueue({ data: enabledAccess, error: null }) // access row + service.enqueue({ data: null, error: null, count: 3 }) // sends used + const response = await GET(createMockRequest('/api/settings/peppol'), createMockRouteParams({})) const body = await response.json() expect(response.status).toBe(200) expect(body.data.receiving_supported).toBe(true) + expect(body.data.access).toMatchObject({ status: 'enabled', send_enabled: true, receive_enabled: true, sent_count: 3, remaining_sends: 47 }) expect(body.data.registration).toMatchObject({ status: 'registered', participant_identifier: '5595386219' }) expect(body.data.registration).not.toHaveProperty('business_card') }) it('POST refuses without a transport and in the sandbox', async () => { delete process.env.PEPPOL_TRANSPORT_PROVIDER - expect((await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))).status).toBe(503) + expect((await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({}))).status).toBe(503) process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia' unregister = registerPeppolTransport(makeTransport()) enqueue({ data: { is_sandbox: true }, error: null }) - const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' })) + const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({})) expect(response.status).toBe(403) expect((await response.json()).error.code).toBe('PEPPOL_SANDBOX_NOT_ALLOWED') }) + it('POST refuses receiving without an access grant, and without the receiving flag', async () => { + const transport = makeTransport() + unregister = registerPeppolTransport(transport) + enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: null, error: null }) // no access row + const locked = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({})) + expect(locked.status).toBe(403) + expect((await locked.json()).error.code).toBe('PEPPOL_ACCESS_REQUIRED') + + reset(); service.reset() + enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: { ...enabledAccess, receive_enabled: false }, error: null }) + const sendOnly = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({})) + expect(sendOnly.status).toBe(403) + expect((await sendOnly.json()).error.code).toBe('PEPPOL_RECEIVING_NOT_ENABLED') + expect(transport.registerRecipient).not.toHaveBeenCalled() + }) + it('POST registers the company and returns the minimized registration', async () => { const transport = makeTransport() unregister = registerPeppolTransport(transport) enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: enabledAccess, error: null }) // access grant with receiving enqueue({ data: { org_number: '559538-6219', company_name: 'Arcim Technology AB', vat_number: 'SE559538621901', city: 'Stockholm', country: 'SE' }, error: null }) service.enqueue({ data: [], error: null }) // existing service.enqueue({ data: { id: 'reg-1' }, error: null }) // insert pending service.enqueue({ data: registeredRow, error: null }) // finalize - const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' })) + const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({})) const body = await response.json() expect(response.status).toBe(201) expect(body.data.registration).toMatchObject({ status: 'registered', participant_scheme: '0007' }) @@ -141,8 +172,9 @@ describe('/api/settings/peppol', () => { it('POST maps a personnummer-based company to a 422 with the reason', async () => { unregister = registerPeppolTransport(makeTransport()) enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: enabledAccess, error: null }) enqueue({ data: { org_number: '800101-1234', company_name: 'Firma', vat_number: null, city: null, country: 'SE' }, error: null }) - const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' })) + const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }), createMockRouteParams({})) expect(response.status).toBe(422) expect((await response.json()).error.code).toBe('PEPPOL_REGISTRATION_PERSONAL_NUMBER') }) @@ -152,13 +184,13 @@ describe('/api/settings/peppol', () => { unregister = registerPeppolTransport(transport) service.enqueue({ data: [registeredRow], error: null }) service.enqueue({ data: { ...registeredRow, status: 'deregistered', deregistered_at: '2026-08-21T17:00:00.000Z' }, error: null }) - const ok = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' })) + const ok = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }), createMockRouteParams({})) expect(ok.status).toBe(200) expect((await ok.json()).data.registration.status).toBe('deregistered') expect(transport.unregisterRecipient).toHaveBeenCalledWith({ scheme: '0007', identifier: '5595386219' }) service.enqueue({ data: [], error: null }) - const missing = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' })) + const missing = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }), createMockRouteParams({})) expect(missing.status).toBe(404) }) }) diff --git a/app/api/settings/peppol/access/__tests__/route.test.ts b/app/api/settings/peppol/access/__tests__/route.test.ts new file mode 100644 index 00000000..1f474bff --- /dev/null +++ b/app/api/settings/peppol/access/__tests__/route.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, createMockRouteParams, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const service = createQueuedMockSupabase() +const requireAuthMock = vi.fn() +const sendEmailMock = vi.fn() +const isConfiguredMock = vi.fn() + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => service.supabase, +})) +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ + sendEmail: (...args: unknown[]) => sendEmailMock(...args), + isConfigured: () => isConfiguredMock(), + }), +})) +vi.mock('@/lib/support', () => ({ + getSupportRecipientEmail: () => 'support@example.test', +})) + +import { POST } from '../route' + +const user = { id: 'user-1', email: 'owner@example.test' } +const requestedRow = { + company_id: 'company-1', + status: 'requested', + max_sends: null, + receive_enabled: false, + requested_at: '2026-08-21T15:00:00.000Z', + requested_by: 'user-1', + request_note: null, + enabled_at: null, + enabled_by: null, + disabled_at: null, + note: null, + created_at: '2026-08-21T15:00:00.000Z', + updated_at: '2026-08-21T15:00:00.000Z', +} + +describe('POST /api/settings/peppol/access', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + service.reset() + isConfiguredMock.mockReturnValue(true) + sendEmailMock.mockResolvedValue({ success: true }) + requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null }) + }) + + function post(body: unknown = {}) { + return POST(createMockRequest('/api/settings/peppol/access', { method: 'POST', body }), createMockRouteParams({})) + } + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await post()).status).toBe(401) + }) + + it('rejects an oversized note', async () => { + const response = await post({ note: 'x'.repeat(2001) }) + expect(response.status).toBe(400) + }) + + it('refuses the sandbox', async () => { + enqueue({ data: { is_sandbox: true }, error: null }) + const response = await post() + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('PEPPOL_SANDBOX_NOT_ALLOWED') + }) + + it('records the request, mails the operators and returns the locked summary', async () => { + enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check + service.enqueue({ data: null, error: null }) // no access row + service.enqueue({ data: requestedRow, error: null }) // upsert + enqueue({ data: { company_name: 'Kund AB', org_number: '556677-8899' }, error: null }) // company settings + service.enqueue({ data: requestedRow, error: null }) // summary read + + const response = await post({ note: 'Vi fakturerar Region Skåne' }) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data.access).toMatchObject({ status: 'requested', send_enabled: false }) + expect(sendEmailMock).toHaveBeenCalledTimes(1) + const mail = sendEmailMock.mock.calls[0][0] as { to: string; subject: string; text: string } + expect(mail.to).toBe('support@example.test') + expect(mail.subject).toContain('Kund AB') + expect(mail.text).toContain('company-1') + expect(mail.text).toContain('Region Skåne') + }) + + it('is idempotent for a repeated request (no second e-mail) and 409 when already enabled', async () => { + enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: requestedRow, error: null }) + service.enqueue({ data: requestedRow, error: null }) + const again = await post() + expect(again.status).toBe(200) + expect(sendEmailMock).not.toHaveBeenCalled() + + reset(); service.reset() + enqueue({ data: { is_sandbox: false }, error: null }) + service.enqueue({ data: { ...requestedRow, status: 'enabled', enabled_at: '2026-08-21T16:00:00.000Z' }, error: null }) + const enabled = await post() + expect(enabled.status).toBe(409) + expect((await enabled.json()).error.code).toBe('PEPPOL_ACCESS_ALREADY_ENABLED') + }) +}) diff --git a/app/api/settings/peppol/access/route.ts b/app/api/settings/peppol/access/route.ts new file mode 100644 index 00000000..268eecca --- /dev/null +++ b/app/api/settings/peppol/access/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { privateNoStore } from '@/lib/api/private-no-store' +import { validateBody } from '@/lib/api/validate' +import { withRouteContext } from '@/lib/api/with-route-context' +import { getBranding } from '@/lib/branding/service' +import { getEmailService } from '@/lib/email/service' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { ensureInitialized } from '@/lib/init' +import { + getPeppolAccessSummary, + requestPeppolAccess, +} from '@/lib/invoices/peppol-access' +import { isSandboxCompany } from '@/lib/sandbox/guard' +import { createServiceClient } from '@/lib/supabase/server' +import { getSupportRecipientEmail } from '@/lib/support' + +ensureInitialized() + +const RequestAccessSchema = z.object({ + note: z.string().trim().max(2000).optional(), +}) + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +/** + * POST /api/settings/peppol/access: the company asks for Peppol access. + * + * Writes the request row (service role; the browser cannot grant itself + * anything) and tells the operators by e-mail. The e-mail is best-effort: the + * row is the source of truth and the operators' script lists open requests. + */ +export const POST = withRouteContext( + 'settings.peppol.access.request', + async (request, { supabase, companyId, user, log, requestId }) => { + const validation = await validateBody(request, RequestAccessSchema) + if (!validation.success) return validation.response + const note = validation.data.note?.trim() || null + + if (await isSandboxCompany(supabase, companyId)) { + return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId })) + } + + const service = createServiceClient() + try { + const result = await requestPeppolAccess({ service, companyId, userId: user.id, note }) + if (!result.ok) { + return privateNoStore(errorResponseFromCode(result.code, log, { requestId })) + } + + if (result.created) { + const { data: company } = await supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .maybeSingle() + const emailService = getEmailService() + if (emailService.isConfigured()) { + const companyName = (company as { company_name?: string | null } | null)?.company_name ?? 'okänt bolag' + const orgNumber = (company as { org_number?: string | null } | null)?.org_number ?? 'saknas' + const sent = await emailService.sendEmail({ + to: getSupportRecipientEmail(), + subject: `[${getBranding().appName.toLowerCase()} peppol] Åtkomstbegäran: ${companyName}`, + replyTo: user.email, + html: [ + `

Bolag: ${escapeHtml(companyName)} (${escapeHtml(orgNumber)})

`, + `

Company ID: ${companyId}

`, + `

Begärd av: ${escapeHtml(user.email ?? '')} (${user.id})

`, + note ? `

${escapeHtml(note).replace(/\n/g, '
')}

` : '', + `

Aktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50

`, + ].join('\n'), + text: `Bolag: ${companyName} (${orgNumber})\nCompany ID: ${companyId}\nBegärd av: ${user.email ?? ''} (${user.id})\n\n${note ?? ''}\n\nAktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50`, + }) + if (!sent.success) { + log.warn('peppol access request e-mail failed', { companyId, reason: sent.error }) + } + } else { + log.warn('peppol access request: e-mail service not configured, request recorded only', { companyId }) + } + } + + const summary = await getPeppolAccessSummary({ supabase: service, service, companyId }) + return privateNoStore(NextResponse.json({ data: { access: summary } }, { status: result.created ? 201 : 200 })) + } catch (err) { + return privateNoStore(errorResponse(err, log, { requestId })) + } + }, + { requireWrite: true }, +) diff --git a/app/api/settings/peppol/route.ts b/app/api/settings/peppol/route.ts index 0871eba0..90a2e16b 100644 --- a/app/api/settings/peppol/route.ts +++ b/app/api/settings/peppol/route.ts @@ -3,6 +3,7 @@ import { privateNoStore } from '@/lib/api/private-no-store' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { ensureInitialized } from '@/lib/init' +import { getPeppolAccess, getPeppolAccessSummary } from '@/lib/invoices/peppol-access' import { deregisterCompanyFromPeppolReceiving, getPeppolRegistration, @@ -52,10 +53,12 @@ export const GET = withRouteContext( const registration = resolved ? await getPeppolRegistration({ supabase, companyId, provider: resolved.provider }) : null + const access = await getPeppolAccessSummary({ supabase, service: createServiceClient(), companyId }) return privateNoStore(NextResponse.json({ data: { transport: availability, receiving_supported: !!resolved?.transport.registerRecipient, + access, registration: registrationPayload(registration), }, })) @@ -76,6 +79,14 @@ export const POST = withRouteContext( if (await isSandboxCompany(supabase, companyId)) { return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId })) } + // Receiving consumes a contracted tenant slot: operators grant it per company. + const access = await getPeppolAccess(createServiceClient(), companyId) + if (!access || access.status !== 'enabled') { + return privateNoStore(errorResponseFromCode('PEPPOL_ACCESS_REQUIRED', log, { requestId })) + } + if (!access.receive_enabled) { + return privateNoStore(errorResponseFromCode('PEPPOL_RECEIVING_NOT_ENABLED', log, { requestId })) + } const { data: settings, error: settingsError } = await supabase .from('company_settings') diff --git a/components/settings/PeppolReceiveSettings.tsx b/components/settings/PeppolReceiveSettings.tsx index 0fa57480..970b4f09 100644 --- a/components/settings/PeppolReceiveSettings.tsx +++ b/components/settings/PeppolReceiveSettings.tsx @@ -2,6 +2,7 @@ import { useTranslations } from 'next-intl' import { useCallback, useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { @@ -22,16 +23,28 @@ interface PeppolRegistrationView { last_error: string | null } +interface PeppolAccessView { + status: 'none' | 'requested' | 'enabled' | 'disabled' + send_enabled: boolean + receive_enabled: boolean + max_sends: number | null + sent_count: number + remaining_sends: number | null +} + interface PeppolSettingsPayload { transport: { available: boolean } receiving_supported: boolean + access: PeppolAccessView registration: PeppolRegistrationView | null } /** - * Receiving e-invoices via Peppol: publishes the company's 0007:orgnr through - * the contracted Access Point. One switch, the truth about its state next to - * it. Sending needs no registration, so this row is only about receiving. + * E-invoicing via Peppol for one company. Access is granted per company by + * the operators (it costs per document and receiving consumes a contracted + * slot), so the first row is the grant itself: ask, wait, see what you got. + * Receiving is a second, separate grant and its switch publishes the + * company's 0007:orgnr through the Access Point. */ export function PeppolReceiveSettings() { const t = useTranslations('settings_peppol') @@ -41,6 +54,7 @@ export function PeppolReceiveSettings() { const [state, setState] = useState(null) const [loadFailed, setLoadFailed] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [isRequesting, setIsRequesting] = useState(false) const load = useCallback(async () => { try { @@ -59,11 +73,39 @@ export function PeppolReceiveSettings() { void load() }, [load]) + const localeKey = locale.startsWith('sv') ? 'sv' : 'en' + const access = state?.access ?? null const registration = state?.registration ?? null const isOn = registration?.status === 'registered' || registration?.status === 'pending' - const available = !!state?.transport.available && !!state?.receiving_supported + const transportAvailable = !!state?.transport.available + const receivingAvailable = transportAvailable && !!state?.receiving_supported && !!access?.receive_enabled - const toggle = useCallback(async (next: boolean) => { + const requestAccess = useCallback(async () => { + setIsRequesting(true) + try { + const response = await fetch('/api/settings/peppol/access', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const body = await response.json().catch(() => null) as { + error?: { code?: string; message?: string; message_en?: string } + } | null + if (!response.ok) throw body?.error ?? new Error() + toast({ title: t('request_sent_title'), description: t('request_sent_description') }) + await load() + } catch (error) { + toast({ + title: t('request_failed_title'), + description: getUserErrorMessage(error, { locale: localeKey }), + variant: 'destructive', + }) + } finally { + setIsRequesting(false) + } + }, [load, localeKey, t, toast]) + + const toggleReceiving = useCallback(async (next: boolean) => { setIsSaving(true) try { const response = await fetch('/api/settings/peppol', { method: next ? 'POST' : 'DELETE' }) @@ -79,43 +121,85 @@ export function PeppolReceiveSettings() { } catch (error) { toast({ title: t('toast_failed_title'), - description: getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' }), + description: getUserErrorMessage(error, { locale: localeKey }), variant: 'destructive', }) await load() } finally { setIsSaving(false) } - }, [load, locale, t, toast]) + }, [load, localeKey, t, toast]) - const statusLabel = (() => { - if (!registration || registration.status === 'deregistered') return t('status_off') - return t(`status_${registration.status}`) + const accessLine = (() => { + if (!access) return null + switch (access.status) { + case 'enabled': return t('access_enabled') + case 'requested': return t('access_requested') + case 'disabled': return t('access_disabled') + default: return t('access_none') + } })() + const sendsLine = access?.send_enabled + ? access.max_sends === null + ? t('sends_unlimited', { used: access.sent_count }) + : t('sends_used', { used: access.sent_count, max: access.max_sends }) + : null + const registrationStatusLabel = !registration || registration.status === 'deregistered' + ? t('status_off') + : t(`status_${registration.status}`) return ( + +
+ {loadFailed ? ( + {t('load_failed')} + ) : state === null ? ( + {t('loading')} + ) : !transportAvailable ? ( + {t('provider_required')} + ) : ( + <> + {accessLine} + {sendsLine && {sendsLine}} + + )} +
+ {state !== null && transportAvailable && (access?.status === 'none' || access?.status === 'disabled') && ( + + + + )} +
+ void toggle(value)} - disabled={isSaving || !canWrite || !available || state === null} + onCheckedChange={(value) => void toggleReceiving(value)} + disabled={isSaving || !canWrite || !receivingAvailable || state === null} aria-label={t('enable_label')} />
- {loadFailed ? ( - {t('load_failed')} - ) : state === null ? ( - {t('loading')} - ) : !available ? ( + {state === null || loadFailed ? ( + {loadFailed ? t('load_failed') : t('loading')} + ) : !transportAvailable ? ( {t('provider_required')} + ) : !receivingAvailable && !isOn ? ( + {t('receive_not_enabled')} ) : ( <> - {statusLabel} + {registrationStatusLabel} {registration && registration.status !== 'deregistered' && ( {t('peppol_id_label')} {registration.participant_scheme}:{registration.participant_identifier} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 28514e28..ba619411 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1330,6 +1330,28 @@ const INVOICE: Record = { message_sv: 'Bolaget är inte registrerat för Peppol-mottagning.', message_en: 'The company is not registered for Peppol receiving.', }, + // Peppol access is granted per company by the operators (#546): locked by + // default, requested from settings, enabled with a sending cap. + PEPPOL_ACCESS_REQUIRED: { + httpStatus: 403, + message_sv: 'Peppol är inte aktiverat för det här bolaget. Begär åtkomst under Inställningar > Fakturering > E-faktura via Peppol, så aktiverar vi det.', + message_en: 'Peppol is not enabled for this company. Request access under Settings > Invoicing > E-invoicing via Peppol and we will enable it.', + }, + PEPPOL_SEND_LIMIT_REACHED: { + httpStatus: 409, + message_sv: 'Bolaget har använt sina Peppol-sändningar. Hör av dig till support för fler.', + message_en: 'The company has used its Peppol sends. Contact support for more.', + }, + PEPPOL_RECEIVING_NOT_ENABLED: { + httpStatus: 403, + message_sv: 'Mottagning via Peppol är inte aktiverad för det här bolaget. Hör av dig till support så öppnar vi en plats.', + message_en: 'Receiving via Peppol is not enabled for this company. Contact support and we will open a slot.', + }, + PEPPOL_ACCESS_ALREADY_ENABLED: { + httpStatus: 409, + message_sv: 'Peppol är redan aktiverat för bolaget.', + message_en: 'Peppol is already enabled for the company.', + }, PEPPOL_REGISTRATION_CAP_REACHED: { httpStatus: 409, message_sv: 'Alla platser för Peppol-mottagning är upptagna just nu. Hör av dig till support så öppnar vi fler. Att skicka e-fakturor fungerar ändå.', diff --git a/lib/invoices/__tests__/peppol-access.test.ts b/lib/invoices/__tests__/peppol-access.test.ts new file mode 100644 index 00000000..9916ba46 --- /dev/null +++ b/lib/invoices/__tests__/peppol-access.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { + checkPeppolSendPermission, + requestPeppolAccess, + setPeppolAccess, + summarizePeppolAccess, + type PeppolAccessRow, +} from '@/lib/invoices/peppol-access' + +const { supabase: mockService, enqueue, reset, calls } = createQueuedMockSupabase() +const service = mockService as unknown as SupabaseClient + +function row(overrides: Partial = {}): PeppolAccessRow { + return { + company_id: 'company-1', + status: 'enabled', + max_sends: 50, + receive_enabled: false, + requested_at: '2026-08-21T15:00:00.000Z', + requested_by: 'user-1', + request_note: null, + enabled_at: '2026-08-21T16:00:00.000Z', + enabled_by: 'jakob', + disabled_at: null, + note: null, + created_at: '2026-08-21T15:00:00.000Z', + updated_at: '2026-08-21T16:00:00.000Z', + ...overrides, + } +} + +describe('summarizePeppolAccess', () => { + it('is locked without a row and reports sends only when enabled', () => { + expect(summarizePeppolAccess(null, 0)).toMatchObject({ status: 'none', send_enabled: false, receive_enabled: false, remaining_sends: null }) + expect(summarizePeppolAccess(row({ status: 'requested' }), 0)).toMatchObject({ status: 'requested', send_enabled: false }) + expect(summarizePeppolAccess(row(), 12)).toMatchObject({ send_enabled: true, max_sends: 50, sent_count: 12, remaining_sends: 38 }) + expect(summarizePeppolAccess(row({ max_sends: null }), 12)).toMatchObject({ max_sends: null, remaining_sends: null }) + expect(summarizePeppolAccess(row({ receive_enabled: true }), 0).receive_enabled).toBe(true) + expect(summarizePeppolAccess(row({ status: 'disabled', receive_enabled: true, disabled_at: 'x' }), 0).receive_enabled).toBe(false) + }) +}) + +describe('checkPeppolSendPermission', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + }) + + it('refuses a company without a grant before counting anything', async () => { + enqueue({ data: null, error: null }) + const result = await checkPeppolSendPermission({ service, companyId: 'company-1' }) + expect(result).toMatchObject({ ok: false, code: 'PEPPOL_ACCESS_REQUIRED' }) + expect(calls.filter((c) => c.table === 'peppol_deliveries')).toHaveLength(0) + }) + + it('refuses a requested or disabled company', async () => { + enqueue({ data: row({ status: 'requested', enabled_at: null }), error: null }) + expect(await checkPeppolSendPermission({ service, companyId: 'company-1' })).toMatchObject({ ok: false, code: 'PEPPOL_ACCESS_REQUIRED' }) + enqueue({ data: row({ status: 'disabled', disabled_at: 'x' }), error: null }) + expect(await checkPeppolSendPermission({ service, companyId: 'company-1' })).toMatchObject({ ok: false, code: 'PEPPOL_ACCESS_REQUIRED' }) + }) + + it('allows an enabled company under its cap and refuses at the cap', async () => { + enqueue({ data: row({ max_sends: 3 }), error: null }) + enqueue({ data: null, error: null, count: 2 }) + expect(await checkPeppolSendPermission({ service, companyId: 'company-1' })).toEqual({ ok: true, remaining: 1 }) + + enqueue({ data: row({ max_sends: 3 }), error: null }) + enqueue({ data: null, error: null, count: 3 }) + expect(await checkPeppolSendPermission({ service, companyId: 'company-1' })).toMatchObject({ ok: false, code: 'PEPPOL_SEND_LIMIT_REACHED' }) + }) + + it('treats a null cap as unlimited', async () => { + enqueue({ data: row({ max_sends: null }), error: null }) + enqueue({ data: null, error: null, count: 999 }) + expect(await checkPeppolSendPermission({ service, companyId: 'company-1' })).toEqual({ ok: true, remaining: null }) + }) +}) + +describe('requestPeppolAccess / setPeppolAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + }) + + it('creates a request, is idempotent on a second ask, and refuses when already enabled', async () => { + enqueue({ data: null, error: null }) + enqueue({ data: row({ status: 'requested', enabled_at: null }), error: null }) + const first = await requestPeppolAccess({ service, companyId: 'company-1', userId: 'user-1', note: 'offentlig sektor' }) + expect(first).toMatchObject({ ok: true, created: true }) + const upsert = calls.find((c) => c.method === 'upsert')?.args[0] as Record + expect(upsert).toMatchObject({ company_id: 'company-1', status: 'requested', requested_by: 'user-1', request_note: 'offentlig sektor' }) + + enqueue({ data: row({ status: 'requested', enabled_at: null }), error: null }) + expect(await requestPeppolAccess({ service, companyId: 'company-1', userId: 'user-1', note: null })).toMatchObject({ ok: true, created: false }) + + enqueue({ data: row(), error: null }) + expect(await requestPeppolAccess({ service, companyId: 'company-1', userId: 'user-1', note: null })).toEqual({ ok: false, code: 'PEPPOL_ACCESS_ALREADY_ENABLED' }) + }) + + it('grants with a cap and receiving flag, and disables without touching the cap', async () => { + enqueue({ data: row({ max_sends: 25, receive_enabled: true }), error: null }) + await setPeppolAccess({ service, companyId: 'company-1', status: 'enabled', maxSends: 25, receiveEnabled: true, by: 'jakob' }) + const granted = calls.find((c) => c.method === 'upsert')?.args[0] as Record + expect(granted).toMatchObject({ status: 'enabled', max_sends: 25, receive_enabled: true, enabled_by: 'jakob', disabled_at: null }) + expect(typeof granted.enabled_at).toBe('string') + + reset() + enqueue({ data: row({ status: 'disabled', disabled_at: 'x' }), error: null }) + await setPeppolAccess({ service, companyId: 'company-1', status: 'disabled', by: 'jakob' }) + const disabled = calls.find((c) => c.method === 'upsert')?.args[0] as Record + expect(disabled.status).toBe('disabled') + expect(disabled.max_sends).toBeUndefined() + expect(typeof disabled.disabled_at).toBe('string') + }) +}) diff --git a/lib/invoices/peppol-access.ts b/lib/invoices/peppol-access.ts new file mode 100644 index 00000000..1f180759 --- /dev/null +++ b/lib/invoices/peppol-access.ts @@ -0,0 +1,183 @@ +/** + * Peppol access per company: locked by default, requested by the company, + * granted (with a sending cap, and separately receiving) by the operators. + * + * Why a gate at all: every transmission through the Access Point is billed + * per document and every receiving identifier consumes a contracted tenant + * slot, so "anyone can toggle it on" is a cost and a contract exposure, not a + * feature. The gate is also the product truth on the invoice page: the send + * action says "ask for access" instead of pretending. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' + +export type PeppolAccessStatus = 'requested' | 'enabled' | 'disabled' + +export interface PeppolAccessRow { + company_id: string + status: PeppolAccessStatus + max_sends: number | null + receive_enabled: boolean + requested_at: string | null + requested_by: string | null + request_note: string | null + enabled_at: string | null + enabled_by: string | null + disabled_at: string | null + note: string | null + created_at: string + updated_at: string +} + +/** What the product shows and gates on. `sent_count` counts real transmissions. */ +export interface PeppolAccessSummary { + status: PeppolAccessStatus | 'none' + send_enabled: boolean + receive_enabled: boolean + max_sends: number | null + sent_count: number + remaining_sends: number | null + requested_at: string | null + enabled_at: string | null +} + +export async function getPeppolAccess( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data, error } = await supabase + .from('peppol_access') + .select('*') + .eq('company_id', companyId) + .maybeSingle() + if (error) throw new Error(`Failed to read Peppol access: ${error.message}`) + return (data as PeppolAccessRow | null) ?? null +} + +/** Transmissions actually handed to the access point (a provider submission id exists). */ +export async function countPeppolSends( + service: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await service + .from('peppol_deliveries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .not('provider_submission_id', 'is', null) + if (error) throw new Error(`Failed to count Peppol sends: ${error.message}`) + return count ?? 0 +} + +export function summarizePeppolAccess(row: PeppolAccessRow | null, sentCount: number): PeppolAccessSummary { + const enabled = row?.status === 'enabled' + const maxSends = enabled ? row?.max_sends ?? null : null + return { + status: row?.status ?? 'none', + send_enabled: enabled, + receive_enabled: enabled && !!row?.receive_enabled, + max_sends: maxSends, + sent_count: sentCount, + remaining_sends: maxSends === null ? null : Math.max(0, maxSends - sentCount), + requested_at: row?.requested_at ?? null, + enabled_at: row?.enabled_at ?? null, + } +} + +export async function getPeppolAccessSummary(args: { + supabase: SupabaseClient + service: SupabaseClient + companyId: string +}): Promise { + const row = await getPeppolAccess(args.supabase, args.companyId) + const sent = row?.status === 'enabled' ? await countPeppolSends(args.service, args.companyId) : 0 + return summarizePeppolAccess(row, sent) +} + +export type PeppolSendPermission = + | { ok: true; remaining: number | null } + | { ok: false; code: 'PEPPOL_ACCESS_REQUIRED' | 'PEPPOL_SEND_LIMIT_REACHED'; summary: PeppolAccessSummary } + +/** The gate the send route asks before it touches the network. */ +export async function checkPeppolSendPermission(args: { + service: SupabaseClient + companyId: string +}): Promise { + const row = await getPeppolAccess(args.service, args.companyId) + if (!row || row.status !== 'enabled') { + return { ok: false, code: 'PEPPOL_ACCESS_REQUIRED', summary: summarizePeppolAccess(row, 0) } + } + const sent = await countPeppolSends(args.service, args.companyId) + const summary = summarizePeppolAccess(row, sent) + if (summary.max_sends !== null && sent >= summary.max_sends) { + return { ok: false, code: 'PEPPOL_SEND_LIMIT_REACHED', summary } + } + return { ok: true, remaining: summary.remaining_sends } +} + +export type RequestPeppolAccessResult = + | { ok: true; row: PeppolAccessRow; created: boolean } + | { ok: false; code: 'PEPPOL_ACCESS_ALREADY_ENABLED' } + +/** + * A company asks for access. Idempotent: a second request keeps the first + * timestamp; a company that already has access is told so. A disabled + * company may ask again (the row goes back to `requested`). + */ +export async function requestPeppolAccess(args: { + service: SupabaseClient + companyId: string + userId: string + note: string | null +}): Promise { + const existing = await getPeppolAccess(args.service, args.companyId) + if (existing?.status === 'enabled') return { ok: false, code: 'PEPPOL_ACCESS_ALREADY_ENABLED' } + if (existing?.status === 'requested') return { ok: true, row: existing, created: false } + + const now = new Date().toISOString() + const { data, error } = await args.service + .from('peppol_access') + .upsert({ + company_id: args.companyId, + status: 'requested', + requested_at: now, + requested_by: args.userId, + request_note: args.note, + disabled_at: null, + }, { onConflict: 'company_id' }) + .select('*') + .single() + if (error || !data) throw new Error(`Failed to request Peppol access: ${error?.message ?? 'no row'}`) + return { ok: true, row: data as PeppolAccessRow, created: !existing } +} + +/** Operator action (service role): grant, adjust or withdraw access. */ +export async function setPeppolAccess(args: { + service: SupabaseClient + companyId: string + status: 'enabled' | 'disabled' + maxSends?: number | null + receiveEnabled?: boolean + by: string + note?: string | null +}): Promise { + const now = new Date().toISOString() + const enabling = args.status === 'enabled' + // Undefined values are dropped by JSON serialization, so an omitted option + // leaves the stored column untouched on re-runs. + const { data, error } = await args.service + .from('peppol_access') + .upsert({ + company_id: args.companyId, + status: args.status, + max_sends: args.maxSends, + receive_enabled: args.receiveEnabled, + note: args.note, + enabled_at: enabling ? now : undefined, + enabled_by: enabling ? args.by : undefined, + disabled_at: enabling ? null : now, + }, { onConflict: 'company_id' }) + .select('*') + .single() + if (error || !data) throw new Error(`Failed to set Peppol access: ${error?.message ?? 'no row'}`) + return data as PeppolAccessRow +} diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 2a7acaf2..047bc8d5 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1023,6 +1023,8 @@ export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record = { * a portable räkenskapsinformation backup. */ export const ARCHIVE_EXCLUDED_TABLES: Record = { + // Operator-side Peppol access grant and sending cap: platform configuration, not the company's räkenskapsinformation. + peppol_access: 'platform access grant (status, sending cap); no bookkeeping content', agent_conversations: 'AI assistant state, not räkenskapsinformation', agent_memory: 'AI assistant state, not räkenskapsinformation', agent_profiles: 'AI assistant state, not räkenskapsinformation', diff --git a/messages/en.json b/messages/en.json index 1ef17b40..92907c3d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2227,7 +2227,20 @@ "toast_registered_description": "The company's Peppol id is published. It can take a moment before it shows in the Peppol directory.", "toast_deregistered_title": "Deregistered from Peppol", "toast_deregistered_description": "The company's Peppol id has been removed at the access point.", - "toast_failed_title": "Could not change the Peppol registration" + "toast_failed_title": "Could not change the Peppol registration", + "access_label": "Access", + "access_none": "Not enabled. Peppol is enabled per company by us; request access and we get back to you.", + "access_requested": "Request sent. We will enable it and get back to you.", + "access_enabled": "Enabled for sending e-invoices.", + "access_disabled": "Switched off. Contact support if you want it back.", + "request_button": "Request Peppol access", + "request_sending": "Sending…", + "request_sent_title": "Request sent", + "request_sent_description": "We will enable Peppol for the company and get back to you.", + "request_failed_title": "Could not send the request", + "sends_used": "{used} of {max} sends used", + "sends_unlimited": "{used} sends, no limit", + "receive_not_enabled": "Receiving is enabled by us on request (limited number of slots)." }, "settings_pdf_print": { "coming_soon": "Coming soon", @@ -4072,6 +4085,8 @@ "peppol_status_business_rejected": "Rejected by the recipient", "peppol_status_no_route": "Recipient has no Peppol registration", "peppol_status_failed": "Failed", + "peppol_access_required": "Request Peppol access under Settings > Invoicing and we will enable it for the company.", + "peppol_send_limit_reached": "The company has used its Peppol sends. Contact support for more.", "pdf_rerender_downloaded_title": "Freshly generated PDF downloaded", "pdf_rerender_preview_title": "Showing a freshly generated PDF", "pdf_preview_blocked_title": "Could not open the preview", diff --git a/messages/sv.json b/messages/sv.json index 1e1f3bfd..db345262 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2227,7 +2227,20 @@ "toast_registered_description": "Bolagets Peppol-id är publicerat. Det kan ta en stund innan det syns i Peppol-katalogen.", "toast_deregistered_title": "Avregistrerad från Peppol", "toast_deregistered_description": "Bolagets Peppol-id är borttaget hos operatören.", - "toast_failed_title": "Kunde inte ändra Peppol-registreringen" + "toast_failed_title": "Kunde inte ändra Peppol-registreringen", + "access_label": "Åtkomst", + "access_none": "Inte aktiverat. Peppol aktiveras per bolag av oss, begär åtkomst så hör vi av oss.", + "access_requested": "Begäran skickad. Vi aktiverar och hör av oss.", + "access_enabled": "Aktiverat för att skicka e-fakturor.", + "access_disabled": "Avstängt. Hör av dig till support om du vill ha det igen.", + "request_button": "Begär åtkomst till Peppol", + "request_sending": "Skickar…", + "request_sent_title": "Begäran skickad", + "request_sent_description": "Vi aktiverar Peppol för bolaget och hör av oss.", + "request_failed_title": "Kunde inte skicka begäran", + "sends_used": "{used} av {max} sändningar använda", + "sends_unlimited": "{used} sändningar, ingen gräns", + "receive_not_enabled": "Mottagning aktiveras av oss på begäran (begränsat antal platser)." }, "settings_pdf_print": { "coming_soon": "Kommer snart", @@ -4072,6 +4085,8 @@ "peppol_status_business_rejected": "Avvisad av mottagaren", "peppol_status_no_route": "Mottagaren saknar Peppol-registrering", "peppol_status_failed": "Misslyckades", + "peppol_access_required": "Begär åtkomst till Peppol under Inställningar > Fakturering, så aktiverar vi det för bolaget.", + "peppol_send_limit_reached": "Bolagets Peppol-sändningar är slut. Hör av dig till support för fler.", "pdf_rerender_downloaded_title": "Nyskapad PDF nedladdad", "pdf_rerender_preview_title": "Nyskapad PDF visas", "pdf_preview_blocked_title": "Kunde inte öppna förhandsgranskningen", diff --git a/scripts/peppol/access.ts b/scripts/peppol/access.ts new file mode 100644 index 00000000..9100704e --- /dev/null +++ b/scripts/peppol/access.ts @@ -0,0 +1,131 @@ +/** + * Operator tool for Peppol access (service role; .env.local points at prod). + * + * npx tsx --env-file=.env.local scripts/peppol/access.ts list + * Open requests first, then every company with a row (status, cap, sends used). + * npx tsx --env-file=.env.local scripts/peppol/access.ts enable [--max-sends N] [--receive] [--by you@accounted.se] [--note "..."] + * Grants sending (capped at N transmissions, omit for no cap) and, with + * --receive, the right to publish the company's identifier (one contracted + * tenant slot). Re-running adjusts the same row. + * npx tsx --env-file=.env.local scripts/peppol/access.ts disable [--note "..."] + * npx tsx --env-file=.env.local scripts/peppol/access.ts show + * + * Nothing here talks to Qvalia; it only flips what the product allows. + */ + +import { createClient } from '@supabase/supabase-js' +import { + countPeppolSends, + getPeppolAccess, + setPeppolAccess, +} from '@/lib/invoices/peppol-access' + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY (run with --env-file=.env.local)') + process.exit(2) +} +const service = createClient(supabaseUrl, serviceRoleKey) + +const [, , command = 'list', target, ...rest] = process.argv + +function flag(name: string): string | null { + const index = rest.indexOf(`--${name}`) + if (index === -1) return null + return rest[index + 1] ?? '' +} + +async function resolveCompanyId(input: string | undefined): Promise { + if (!input) throw new Error('company id or organisation number required') + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input)) return input + const digits = input.replace(/\D/g, '') + const { data, error } = await service + .from('company_settings') + .select('company_id, company_name, org_number') + .limit(2000) + if (error) throw error + const hits = ((data ?? []) as Array<{ company_id: string; company_name: string | null; org_number: string | null }>) + .filter((row) => (row.org_number ?? '').replace(/\D/g, '') === digits) + if (hits.length !== 1) throw new Error(`${hits.length} companies match org number ${input}`) + return hits[0].company_id +} + +async function describe(companyId: string): Promise { + const { data } = await service + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .maybeSingle() + const row = data as { company_name: string | null; org_number: string | null } | null + return `${row?.company_name ?? '?'} (${row?.org_number ?? '?'}) ${companyId}` +} + +async function main(): Promise { + switch (command) { + case 'list': { + const { data, error } = await service + .from('peppol_access') + .select('*') + .order('status', { ascending: true }) + .order('requested_at', { ascending: true }) + if (error) throw error + const rows = (data ?? []) as Array<{ company_id: string; status: string; max_sends: number | null; receive_enabled: boolean; requested_at: string | null; enabled_at: string | null; request_note: string | null }> + if (rows.length === 0) { console.log('No Peppol access rows.'); return } + for (const row of rows) { + const sends = row.status === 'enabled' ? await countPeppolSends(service, row.company_id) : 0 + console.log( + `${row.status.padEnd(9)} ${await describe(row.company_id)} sends ${sends}/${row.max_sends ?? '∞'} receive=${row.receive_enabled}` + + (row.requested_at ? ` requested ${row.requested_at}` : '') + + (row.request_note ? `\n note: ${row.request_note}` : ''), + ) + } + return + } + case 'show': { + const companyId = await resolveCompanyId(target) + console.log(await describe(companyId)) + console.log(JSON.stringify(await getPeppolAccess(service, companyId), null, 2)) + console.log('sends used:', await countPeppolSends(service, companyId)) + return + } + case 'enable': { + const companyId = await resolveCompanyId(target) + const maxSendsRaw = flag('max-sends') + const maxSends = maxSendsRaw === null ? undefined : (maxSendsRaw === '' || maxSendsRaw === 'none' ? null : Number.parseInt(maxSendsRaw, 10)) + if (maxSends !== undefined && maxSends !== null && !Number.isFinite(maxSends)) throw new Error('--max-sends expects a number or "none"') + const row = await setPeppolAccess({ + service, + companyId, + status: 'enabled', + maxSends, + receiveEnabled: rest.includes('--receive') ? true : (rest.includes('--no-receive') ? false : undefined), + by: flag('by') ?? `scripts/peppol/access.ts (${process.env.USER ?? 'operator'})`, + note: flag('note') ?? undefined, + }) + console.log(`enabled: ${await describe(companyId)}`) + console.log(JSON.stringify(row, null, 2)) + return + } + case 'disable': { + const companyId = await resolveCompanyId(target) + const row = await setPeppolAccess({ + service, + companyId, + status: 'disabled', + by: flag('by') ?? `scripts/peppol/access.ts (${process.env.USER ?? 'operator'})`, + note: flag('note') ?? undefined, + }) + console.log(`disabled: ${await describe(companyId)}`) + console.log(JSON.stringify(row, null, 2)) + return + } + default: + throw new Error(`Unknown command "${command}". See the header comment.`) + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/supabase/migrations/20260821180000_peppol_access.sql b/supabase/migrations/20260821180000_peppol_access.sql new file mode 100644 index 00000000..379ff22e --- /dev/null +++ b/supabase/migrations/20260821180000_peppol_access.sql @@ -0,0 +1,61 @@ +-- Peppol access is granted per company, never self-served (#546). +-- +-- Every Peppol transmission costs money at the Access Point and every +-- receiving identifier consumes a contracted tenant slot, so a company is +-- locked out by default, asks for access from the settings page, and the +-- operators enable it (with a sending cap) from the service role. The table +-- is readable by the company's members and written by nobody but the service +-- role: the browser can ask, it can never grant itself anything. + +CREATE TABLE public.peppol_access ( + company_id uuid PRIMARY KEY REFERENCES public.companies(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'requested' + CHECK (status IN ('requested', 'enabled', 'disabled')), + -- Cap on transmissions through the access point; null = no cap. + max_sends integer CHECK (max_sends IS NULL OR max_sends >= 0), + -- Receiving (publishing the company's identifier) is a separate, scarcer + -- grant: it consumes one of the contracted tenant slots. + receive_enabled boolean NOT NULL DEFAULT false, + requested_at timestamptz, + requested_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + request_note text CHECK (request_note IS NULL OR length(request_note) <= 2000), + enabled_at timestamptz, + -- Free-text label of who granted it (operator e-mail or script name); + -- operators are not application users, so no FK. + enabled_by text, + disabled_at timestamptz, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT peppol_access_status_shape CHECK ( + (status = 'enabled' AND enabled_at IS NOT NULL) + OR (status = 'disabled' AND disabled_at IS NOT NULL) + OR status = 'requested' + ) +); + +CREATE INDEX peppol_access_status_idx ON public.peppol_access (status, requested_at); + +CREATE TRIGGER set_peppol_access_updated_at + BEFORE UPDATE ON public.peppol_access + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +ALTER TABLE public.peppol_access ENABLE ROW LEVEL SECURITY; +CREATE POLICY "view own-company peppol access" + ON public.peppol_access FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); +-- Supabase's default privileges hand every new table ALL to authenticated; +-- take that back so a member's UPDATE is a hard "permission denied" rather +-- than an RLS-filtered no-op, then grant the one thing members need. +REVOKE ALL ON public.peppol_access FROM PUBLIC, anon, authenticated; +GRANT SELECT ON public.peppol_access TO authenticated; + +-- Same tightening for the two receiving tables from 20260821170000, which +-- revoked from PUBLIC and anon only (their RLS already blocked writes; this +-- makes the refusal explicit at the privilege level too). +REVOKE ALL ON public.peppol_registrations FROM PUBLIC, anon, authenticated; +GRANT SELECT ON public.peppol_registrations TO authenticated; +REVOKE ALL ON public.peppol_inbound_documents FROM PUBLIC, anon, authenticated; +GRANT SELECT ON public.peppol_inbound_documents TO authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/peppol-access.pg.test.ts b/tests/pg/peppol-access.pg.test.ts new file mode 100644 index 00000000..4f99b077 --- /dev/null +++ b/tests/pg/peppol-access.pg.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { getPool, runAsServiceRole, withUserContext } from './setup' +import { seedCompany } from './fixtures' + +describe('peppol_access', () => { + it('is readable by the company members only and never writable by authenticated users', async () => { + const own = await seedCompany() + const other = await seedCompany() + await getPool().query( + `INSERT INTO public.peppol_access (company_id, status, max_sends, enabled_at, enabled_by) + VALUES ($1, 'enabled', 25, now(), 'test'), ($2, 'requested', NULL, NULL, NULL)`, + [own.companyId, other.companyId], + ) + + const visible = await withUserContext(own.userId, async (client) => { + const { rows } = await client.query(`SELECT company_id, status, max_sends FROM public.peppol_access`) + return rows + }) + expect(visible).toEqual([{ company_id: own.companyId, status: 'enabled', max_sends: 25 }]) + + await expect(withUserContext(own.userId, (client) => + client.query(`UPDATE public.peppol_access SET max_sends = 1000000 WHERE company_id = $1`, [own.companyId]), + )).rejects.toThrow(/permission denied|row-level security/) + await expect(withUserContext(other.userId, (client) => + client.query( + `INSERT INTO public.peppol_access (company_id, status, enabled_at) VALUES ($1, 'enabled', now()) + ON CONFLICT (company_id) DO UPDATE SET status = 'enabled', enabled_at = now()`, + [other.companyId], + ), + )).rejects.toThrow(/permission denied|row-level security/) + + const serviceView = await runAsServiceRole(async (client) => { + const { rows } = await client.query(`SELECT count(*)::int AS n FROM public.peppol_access`) + return rows[0].n as number + }) + expect(serviceView).toBeGreaterThanOrEqual(2) + }) + + it('keeps the status shape honest: enabled needs enabled_at, disabled needs disabled_at', async () => { + const seeded = await seedCompany() + await expect(getPool().query( + `INSERT INTO public.peppol_access (company_id, status) VALUES ($1, 'enabled')`, [seeded.companyId], + )).rejects.toThrow(/peppol_access_status_shape/) + await expect(getPool().query( + `INSERT INTO public.peppol_access (company_id, status, disabled_at) VALUES ($1, 'disabled', now())`, [seeded.companyId], + )).resolves.toBeTruthy() + await expect(getPool().query( + `UPDATE public.peppol_access SET max_sends = -1 WHERE company_id = $1`, [seeded.companyId], + )).rejects.toThrow(/peppol_access_max_sends_check/) + }) +})