From 07e89d9b5280a9f76ad7018d6a13f01cb29dbce6 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:44:32 +0200 Subject: [PATCH] feat(invoices): add Peppol delivery foundation (#1595) * feat(invoices): add Peppol delivery foundation * fix(invoices): harden Peppol compliance guards * fix(api): narrow Peppol document loading * test(pg): hash Peppol fixture payload * fix(invoices): address Peppol review findings * test(pg): isolate Peppol provider events * test(pg): isolate Peppol submission fixtures --- DECISIONS.md | 1 + app/(dashboard)/invoices/[id]/page.tsx | 81 +- .../[id]/peppol/__tests__/route.test.ts | 121 ++- .../peppol/deliveries/__tests__/route.test.ts | 99 +++ .../invoices/[id]/peppol/deliveries/route.ts | 47 ++ app/api/invoices/[id]/peppol/route.ts | 212 ++++-- docs/PEPPOL_FOUNDATION.md | 47 +- lib/api/private-no-store.ts | 6 + .../__tests__/structured-errors.test.ts | 9 + lib/errors/get-structured-error.ts | 1 + .../__tests__/peppol-bis-billing.test.ts | 31 + .../__tests__/peppol-delivery.test.ts | 10 + .../__tests__/peppol-transport.test.ts | 74 ++ lib/invoices/peppol-bis-billing.ts | 10 +- lib/invoices/peppol-delivery.ts | 128 ++++ lib/invoices/peppol-transport.ts | 141 ++++ lib/reports/full-archive-export.ts | 9 + messages/en.json | 7 + messages/sv.json | 7 + ...260813162752_peppol_delivery_lifecycle.sql | 713 ++++++++++++++++++ ...260813163034_peppol_delivery_hardening.sql | 196 +++++ ...31_peppol_delivery_payload_hash_guards.sql | 54 ++ ...165814_peppol_delivery_retention_basis.sql | 46 ++ ...71856_peppol_retention_source_of_truth.sql | 103 +++ tests/pg/peppol-delivery-lifecycle.pg.test.ts | 307 ++++++++ 25 files changed, 2376 insertions(+), 84 deletions(-) create mode 100644 app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/peppol/deliveries/route.ts create mode 100644 lib/api/private-no-store.ts create mode 100644 lib/invoices/__tests__/peppol-delivery.test.ts create mode 100644 lib/invoices/__tests__/peppol-transport.test.ts create mode 100644 lib/invoices/peppol-delivery.ts create mode 100644 lib/invoices/peppol-transport.ts create mode 100644 supabase/migrations/20260813162752_peppol_delivery_lifecycle.sql create mode 100644 supabase/migrations/20260813163034_peppol_delivery_hardening.sql create mode 100644 supabase/migrations/20260813164931_peppol_delivery_payload_hash_guards.sql create mode 100644 supabase/migrations/20260813165814_peppol_delivery_retention_basis.sql create mode 100644 supabase/migrations/20260813171856_peppol_retention_source_of_truth.sql create mode 100644 tests/pg/peppol-delivery-lifecycle.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4b3c9b14..565d9a9c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -935,6 +935,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Skeptic refutation fix on the renewal guard: the picker's gap-fill probe keys its state by connectionId and lists `accounts` in its deps, so the pre-existing reset effect (re-runs on every accounts identity change, e.g. the panel's visibility refetch after a BankID app switch) can never wipe the renewal default without a matching re-probe. Backfill sweep hardening from the same pass: accounts whose cash_accounts row did not resolve (found: false) are skipped instead of degrading to the pooled currency-only form, and the sweep window opens at the oldest returned booking date when the bank over-returns history. resolveGapFillStart clamps to the backend's 365-day floor so the shown date always matches the actual backfill start. [2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client. [2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees. +[2026-08-13] Issue #546 Peppol transport foundation uses separate immutable staging, event, and evidence records with a disabled send gate: Corner 3 transport is not buyer acceptance, raw out-of-order events must remain auditable, and Storecove or Qvalia credentials plus a verified webhook contract are still external product inputs. [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 978b4a03..54cce160 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -31,6 +31,7 @@ import { ArrowLeft, Send, CheckCircle, + FileCheck2, FileText, Download, Eye, @@ -160,6 +161,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [isUpdating, setIsUpdating] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [isDownloadingPeppol, setIsDownloadingPeppol] = useState(false) + const [isPreparingPeppol, setIsPreparingPeppol] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [showFinalizeDialog, setShowFinalizeDialog] = useState(false) @@ -628,6 +630,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st } } + async function preparePeppolDelivery() { + if (!invoice) return + setIsPreparingPeppol(true) + + try { + const response = await fetch(`/api/invoices/${invoice.id}/peppol`, { method: 'POST' }) + 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(t('peppol_prepare_failed_description')) + } + + toast({ + title: t('peppol_prepared_title'), + description: t('peppol_prepared_description'), + }) + } catch (error) { + toast({ + title: t('peppol_prepare_failed_title'), + description: error instanceof Error + ? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' }) + : getUserErrorMessage(error, { + context: 'invoice', + locale: locale.startsWith('sv') ? 'sv' : 'en', + }), + variant: 'destructive', + }) + } finally { + setIsPreparingPeppol(false) + } + } + /** * Show one specific document in the browser instead of saving it (#1190): * granskning should not require leaving the app for the Downloads folder. @@ -1071,18 +1106,40 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )} {!isSelfBilled && isRealInvoice && !isCreditNote && invoice.invoice_number && ( - + <> + + + + + {t('peppol_provider_required')} + + )} diff --git a/app/api/invoices/[id]/peppol/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/__tests__/route.test.ts index fb4ca831..c84c940e 100644 --- a/app/api/invoices/[id]/peppol/__tests__/route.test.ts +++ b/app/api/invoices/[id]/peppol/__tests__/route.test.ts @@ -22,7 +22,11 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) -import { GET } from '../route' +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { GET, POST } from '../route' const INVOICE_ID = '11111111-1111-4111-8111-111111111111' const user = { id: 'user-1', email: 'owner@example.test' } @@ -153,3 +157,118 @@ describe('GET /api/invoices/[id]/peppol', () => { expect(xml).toContain('125.00') }) }) + +describe('POST /api/invoices/[id]/peppol', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + delete process.env.PEPPOL_TRANSPORT_PROVIDER + requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null }) + }) + + it('returns 401 when the caller is not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }), + createMockRouteParams({ id: INVOICE_ID }), + ) + + expect(response.status).toBe(401) + }) + + it('returns 400 for an invalid invoice id', async () => { + const response = await POST( + createMockRequest('/api/invoices/not-a-uuid/peppol', { method: 'POST' }), + createMockRouteParams({ id: 'not-a-uuid' }), + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 when the invoice does not exist in the active company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const response = await POST( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }), + createMockRouteParams({ id: INVOICE_ID }), + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND') + }) + + it('stages the exact XML but truthfully reports that nothing was sent', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + enqueue({ + data: { + id: '22222222-2222-4222-8222-222222222222', + invoice_id: INVOICE_ID, + idempotency_key: '33333333-3333-4333-8333-333333333333', + xml_sha256: 'a'.repeat(64), + status: 'staged', + filename: 'peppol-invoice-F-2026-42.xml', + created_at: '2026-08-13T16:00:00.000Z', + }, + error: null, + }) + + const response = await POST( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }), + createMockRouteParams({ id: INVOICE_ID }), + ) + const body = await response.json() + + expect(response.status).toBe(201) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(body.data).toMatchObject({ + status: 'staged', + network_submitted: false, + transport: { + available: false, + provider: null, + reason: 'provider_selection_required', + }, + }) + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'stage_peppol_delivery', + expect.objectContaining({ + p_company_id: 'company-1', + p_invoice_id: INVOICE_ID, + p_recipient_scheme: '0007', + p_recipient_identifier: '5566778899', + p_xml_payload: expect.stringContaining('380'), + p_xml_sha256: expect.stringMatching(/^[0-9a-f]{64}$/), + }), + ) + }) + + it.each([ + ['42501', 403, 'FORBIDDEN'], + ['P0002', 404, 'NOT_FOUND'], + ])('preserves staging SQLSTATE %s as an expected API response', async ( + code, + expectedStatus, + expectedCode, + ) => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + enqueue({ data: null, error: { code, message: 'staging rejected' } }) + + const response = await POST( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }), + createMockRouteParams({ id: INVOICE_ID }), + ) + const body = await response.json() + + expect(response.status).toBe(expectedStatus) + expect(body.error.code).toBe(expectedCode) + expect(body.error.details).toMatchObject({ pgCode: code }) + }) +}) diff --git a/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts new file mode 100644 index 00000000..7dde2fff --- /dev/null +++ b/app/api/invoices/[id]/peppol/deliveries/__tests__/route.test.ts @@ -0,0 +1,99 @@ +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 requireAuthMock = vi.fn() + +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +const INVOICE_ID = '11111111-1111-4111-8111-111111111111' +const user = { id: 'user-1', email: 'owner@example.test' } + +describe('GET /api/invoices/[id]/peppol/deliveries', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + delete process.env.PEPPOL_TRANSPORT_PROVIDER + requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null }) + }) + + it('returns 401 when the caller is not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`), + createMockRouteParams({ id: INVOICE_ID }), + ) + + expect(response.status).toBe(401) + }) + + it('returns 400 for an invalid invoice id', async () => { + const response = await GET( + createMockRequest('/api/invoices/not-a-uuid/peppol/deliveries'), + createMockRouteParams({ id: 'not-a-uuid' }), + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 for an invoice outside the active company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const response = await GET( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`), + createMockRouteParams({ id: INVOICE_ID }), + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND') + }) + + it('returns minimized delivery status and a truthful transport gate', async () => { + enqueue({ data: { id: INVOICE_ID }, error: null }) + enqueue({ + data: [{ + id: '22222222-2222-4222-8222-222222222222', + status: 'transport_succeeded', + provider: 'storecove', + xml_sha256: 'a'.repeat(64), + }], + error: null, + }) + + const response = await GET( + createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`), + createMockRouteParams({ id: INVOICE_ID }), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(body.data).toEqual([ + expect.objectContaining({ status: 'transport_succeeded', provider: 'storecove' }), + ]) + expect(body.transport).toEqual({ + available: false, + provider: null, + reason: 'provider_selection_required', + }) + }) +}) diff --git a/app/api/invoices/[id]/peppol/deliveries/route.ts b/app/api/invoices/[id]/peppol/deliveries/route.ts new file mode 100644 index 00000000..e416887c --- /dev/null +++ b/app/api/invoices/[id]/peppol/deliveries/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +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 { listPeppolDeliverySummaries } from '@/lib/invoices/peppol-delivery' +import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport' + +const paramsSchema = z.object({ id: z.uuid() }) + +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.peppol.deliveries.list', + async (_request, { supabase, companyId, log, requestId }, { params }) => { + const parsedParams = paramsSchema.safeParse(await params) + if (!parsedParams.success) { + return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + details: { fields: parsedParams.error.flatten().fieldErrors }, + })) + } + const invoiceId = parsedParams.data.id + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .select('id') + .eq('id', invoiceId) + .eq('company_id', companyId) + .single() + if (invoiceError || !invoice) { + return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })) + } + + try { + const deliveries = await listPeppolDeliverySummaries({ + supabase, + companyId, + invoiceId, + }) + return privateNoStore(NextResponse.json({ + data: deliveries, + transport: getPeppolTransportAvailability(), + })) + } catch (err) { + return privateNoStore(errorResponse(err, log, { requestId })) + } + }, +) diff --git a/app/api/invoices/[id]/peppol/route.ts b/app/api/invoices/[id]/peppol/route.ts index b6c97ebc..7bef4c9c 100644 --- a/app/api/invoices/[id]/peppol/route.ts +++ b/app/api/invoices/[id]/peppol/route.ts @@ -1,16 +1,110 @@ import { NextResponse } from 'next/server' import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' import { contentDisposition } from '@/lib/api/content-disposition' +import { privateNoStore } from '@/lib/api/private-no-store' import { withRouteContext } from '@/lib/api/with-route-context' -import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { generatePeppolBisBillingInvoice } from '@/lib/invoices/peppol-bis-billing' +import { stagePeppolDelivery } from '@/lib/invoices/peppol-delivery' +import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport' import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' const paramsSchema = z.object({ id: z.uuid() }) +type GeneratedPeppolInvoice = Extract< + ReturnType, + { ok: true } +> +type LoadPeppolDocumentResult = + | { ok: true; document: GeneratedPeppolInvoice } + | { ok: false; response: NextResponse } -function privateNoStore(response: NextResponse): NextResponse { - response.headers.set('Cache-Control', 'private, no-store') - return response +async function loadPeppolDocument(args: { + supabase: SupabaseClient + companyId: string + invoiceId: string + log: Parameters[1] + requestId: string +}): Promise { + const { data: invoice, error: invoiceError } = await args.supabase + .from('invoices') + .select(` + *, + customer:customers(*), + items:invoice_items(*) + `) + .eq('id', args.invoiceId) + .eq('company_id', args.companyId) + .single() + + if (invoiceError || !invoice) { + return { + ok: false, + response: privateNoStore(errorResponseFromCode( + 'INVOICE_NOT_FOUND', + args.log, + { requestId: args.requestId }, + )), + } + } + + const { data: company, error: companyError } = await args.supabase + .from('company_settings') + .select('*') + .eq('company_id', args.companyId) + .single() + + if (companyError || !company) { + return { + ok: false, + response: privateNoStore(errorResponseFromCode( + 'INVOICE_SEND_COMPANY_SETTINGS_MISSING', + args.log, + { requestId: args.requestId }, + )), + } + } + + const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] } + if (!typedInvoice.customer) { + return { + ok: false, + response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, { + requestId: args.requestId, + messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.', + messageEn: 'The invoice has no customer available for Peppol export.', + details: { field: 'invoice.customer' }, + })), + } + } + + const document = generatePeppolBisBillingInvoice({ + invoice: typedInvoice, + customer: typedInvoice.customer, + items: typedInvoice.items ?? [], + company: company as CompanySettings, + }) + if (!document.ok) { + const first = document.issues[0] + return { + ok: false, + response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, { + requestId: args.requestId, + messageSv: first?.messageSv, + messageEn: first?.messageEn, + details: { + issues: document.issues.map((item) => ({ + code: item.code, + field: item.field, + message_sv: item.messageSv, + message_en: item.messageEn, + })), + }, + })), + } + } + + return { ok: true, document } } export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( @@ -25,67 +119,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( } const { id } = parsedParams.data - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select(` - *, - customer:customers(*), - items:invoice_items(*) - `) - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (invoiceError || !invoice) { - return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })) - } - - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', companyId) - .single() - - if (companyError || !company) { - return privateNoStore(errorResponseFromCode( - 'INVOICE_SEND_COMPANY_SETTINGS_MISSING', - log, - { requestId }, - )) - } - - const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] } - if (!typedInvoice.customer) { - return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, { - requestId, - messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.', - messageEn: 'The invoice has no customer available for Peppol export.', - details: { field: 'invoice.customer' }, - })) - } - - const result = generatePeppolBisBillingInvoice({ - invoice: typedInvoice, - customer: typedInvoice.customer, - items: typedInvoice.items ?? [], - company: company as CompanySettings, - }) - if (!result.ok) { - const first = result.issues[0] - return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, { - requestId, - messageSv: first?.messageSv, - messageEn: first?.messageEn, - details: { - issues: result.issues.map((item) => ({ - code: item.code, - field: item.field, - message_sv: item.messageSv, - message_en: item.messageEn, - })), - }, - })) - } + const loaded = await loadPeppolDocument({ supabase, companyId, invoiceId: id, log, requestId }) + if (!loaded.ok) return loaded.response + const result = loaded.document return new NextResponse(result.xml, { status: 200, @@ -98,3 +134,49 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( }) }, ) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.peppol.stage', + async (_request, { supabase, companyId, log, requestId }, { params }) => { + const parsedParams = paramsSchema.safeParse(await params) + if (!parsedParams.success) { + return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + details: { fields: parsedParams.error.flatten().fieldErrors }, + })) + } + + const invoiceId = parsedParams.data.id + const loaded = await loadPeppolDocument({ + supabase, + companyId, + invoiceId, + log, + requestId, + }) + if (!loaded.ok) return loaded.response + + try { + const delivery = await stagePeppolDelivery({ + supabase, + companyId, + invoiceId, + document: loaded.document, + }) + return privateNoStore(NextResponse.json({ + data: { + id: delivery.id, + idempotency_key: delivery.idempotency_key, + xml_sha256: delivery.xml_sha256, + status: delivery.status, + created_at: delivery.created_at, + network_submitted: false, + transport: getPeppolTransportAvailability(), + }, + }, { status: 201 })) + } catch (err) { + return privateNoStore(errorResponse(err, log, { requestId })) + } + }, + { requireWrite: true }, +) diff --git a/docs/PEPPOL_FOUNDATION.md b/docs/PEPPOL_FOUNDATION.md index 0c9d6743..3e688ba8 100644 --- a/docs/PEPPOL_FOUNDATION.md +++ b/docs/PEPPOL_FOUNDATION.md @@ -5,12 +5,25 @@ Issue #546 requires two separable capabilities: 1. Produce a correctly structured Peppol BIS Billing 3 invoice from Accounted data. 2. Deliver and receive documents through the Peppol network. -This change implements the first capability for a deliberately constrained Swedish invoice profile. It does not claim network delivery. +The first slice implemented the invoice profile. The second slice adds an immutable staged-delivery and audit foundation. Neither slice claims network delivery. ## Implemented scope `GET /api/invoices/{id}/peppol` produces a UBL 2.1 invoice with the Peppol BIS Billing 3 CustomizationID and ProfileID. It is available to an authenticated member of the active company and applies the same explicit `company_id` isolation as other invoice routes. +`POST /api/invoices/{id}/peppol` now stores the exact generated XML as an immutable staged delivery. Staging assigns a stable UUID idempotency key, stores the recipient, profile identifiers, filename, SHA-256, retention date, and an append-only local audit event. It explicitly returns `network_submitted: false`. Repeating the request for the same invoice and XML returns the existing staged record. + +`GET /api/invoices/{id}/peppol/deliveries` returns a minimized status timeline projection without exposing XML, raw webhooks, or provider evidence. The invoice page can prepare a delivery, but its network send control remains disabled with a provider-required explanation. + +The provider-neutral `PeppolTransport` boundary separates: + +- recipient lookup and advertised document/process capabilities; +- idempotent submission and provider correlation; +- cryptographically verified webhook normalization; +- evidence retrieval, including an optional exact transmitted document. + +No adapter is registered by core and no environment value can make an absent adapter appear available. + The export supports: - numbered standard sales invoices, not credit notes, self-billing, proformas, or delivery notes; @@ -49,11 +62,13 @@ A provider adapter needs at least: - authenticated webhooks or polling for final transport status; - test and production environments with equivalent validation behavior. -The existing email delivery model cannot honestly represent Peppol receipts. Provider selection should precede the database design because provider status vocabularies and webhook guarantees determine the durable state machine. +The existing email delivery model cannot honestly represent Peppol receipts. The Peppol-specific model therefore records `staged`, recipient lookup, submission acceptance, Corner 3 transport success, recipient acknowledgement, and business acceptance or rejection separately. Every verified raw event remains append-only even when it is duplicated or arrives out of order. The latest normalized status is only a projection. ### Status and audit handling -After provider selection, add a migration and pg-real tests for a Peppol-specific delivery record. It should preserve the exact submitted XML, its SHA-256 hash, recipient scheme and identifier, provider id, attempt timestamps, normalized status, raw receipt metadata, and immutable failure history. A successful API acceptance is not the same as network delivery. +The Peppol-specific tables now preserve the exact staged XML, its SHA-256 hash, recipient scheme and identifier, provider and tenant correlation, attempt timestamps, normalized status, raw verified event metadata, immutable failure history, and retrieved evidence. A successful API acceptance is not the same as Corner 3 transport, and Corner 3 transport is not the same as buyer acknowledgement or business acceptance. + +Provider event and evidence RPCs are service-role only. Future webhook routes must first use the selected adapter to authenticate and normalize the provider payload, then persist the verified event. A public webhook endpoint is intentionally not exposed before its authentication contract is known. No invoice status should change merely because XML was generated or accepted by a provider. The send workflow must define exactly which provider receipt constitutes delivery, how retries preserve idempotency, and how a permanent rejection is shown without mutating the posted invoice. @@ -63,11 +78,19 @@ Inbound invoices are a separate acceptance slice. It requires provider webhook a ### UI and API -The current UI downloads a locally checked XML file and states that it was not sent. Once delivery exists, sending should be a distinct confirmation flow that performs recipient lookup, shows the discovered recipient, and records the resulting delivery timeline. The download should remain available for diagnosis and interoperability testing. +The UI downloads a locally checked XML file and can prepare an immutable delivery snapshot. Both actions state that they did not send the invoice. Once an adapter exists, sending must be a distinct confirmation flow that performs recipient lookup, shows the discovered participant and capabilities, and records the resulting timeline. The download remains available for diagnosis and interoperability testing. ### Credentials and commercial decision -Emil must choose and contract a certified access-point provider before full send or receive can be completed. The decision needs verified answers for: +Emil must choose and contract a certified access-point provider before full send or receive can be completed. + +### Storecove versus Qvalia + +Storecove is the stronger fit for the lifecycle already modeled. Its official API documents recipient discovery, caller-supplied `idempotencyGuid`, a returned submission `guid`, tenant correlation, asynchronous sending webhooks, and a dedicated evidence endpoint. Its sandbox supports webhook simulation and the OpenPeppol test network. A Storecove adapter still requires a commercial contract and credentials; these public semantics do not prove Accounted's tenant is authorized or onboarded. + +Qvalia is a Swedish certified Access Point and SMP with an explicit partner and multi-tenant offering. Its public quick start documents production and sandbox endpoints, account registration numbers, and separate keys. Public material does not currently specify a Storecove-equivalent contract for idempotency, signed webhooks, event ordering, or exact transmitted-document evidence. Those points must be obtained from Qvalia Sales or Support before an adapter can be production quality. + +Inputs required for either selection: - multitenant or reseller authorization for Accounted customer companies; - setup, monthly, per-document, inbound, lookup, and support pricing; @@ -77,6 +100,16 @@ Emil must choose and contract a certified access-point provider before full send - webhook signing, retention, service levels, and data-processing terms; - support for the mandatory May 2026 Peppol release. +Additional API contract inputs required before implementation: + +- the external tenant, legal entity, and account identifiers for every Accounted company; +- exact discovery request and response semantics for `0007` and `0088` participants; +- idempotency retention, duplicate response behavior, and retry guarantees; +- webhook signature or authentication scheme, secret rotation, replay window, event identifiers, retry policy, and ordering guarantees; +- exact meanings of transport, acknowledgement, acceptance, rejection, and terminal events; +- evidence endpoint response, retention, exact-document guarantees, and audit export format; +- sandbox participant IDs, production onboarding checks, and agreed conformance acceptance tests. + Provider credentials and prices are external operational inputs. They are not invented, stored in source, or represented by a fake provider in this change. ## Primary specifications and authority guidance @@ -87,3 +120,7 @@ Provider credentials and prices are external operational inputs. They are not in - [European Commission EN 16931 validation artefacts](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931) - [SFTI Peppol BIS Billing 3 guidance](https://www.sfti.se/sfti/standarder/peppolbisochpeppolinfrastruktur/peppolbisbilling3.26609.html) - [Swedish authority guidance for connecting to Peppol](https://www.upphandlingsmyndigheten.se/inkopsprocessen/e-handel/peppol/anslut-till-peppol/) +- [Storecove API documentation](https://www.storecove.com/docs/) +- [Qvalia API quick start](https://api.qvalia.io/quick-start) +- [Qvalia API environments and formats](https://api.qvalia.io/api-documentation/apis) +- [Qvalia partner API and Peppol infrastructure](https://qvalia.com/help/overview-of-qvalias-partner-api-and-peppol-infrastructure/) diff --git a/lib/api/private-no-store.ts b/lib/api/private-no-store.ts new file mode 100644 index 00000000..03c7769b --- /dev/null +++ b/lib/api/private-no-store.ts @@ -0,0 +1,6 @@ +import type { NextResponse } from 'next/server' + +export function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} diff --git a/lib/errors/__tests__/structured-errors.test.ts b/lib/errors/__tests__/structured-errors.test.ts index 0cc291ac..b22c587f 100644 --- a/lib/errors/__tests__/structured-errors.test.ts +++ b/lib/errors/__tests__/structured-errors.test.ts @@ -101,6 +101,15 @@ describe('errorResponse', () => { expect(body.error.details).toMatchObject({ pgCode: '23505' }) }) + it('maps Postgres no-data-found to NOT_FOUND with pgCode', async () => { + const pgErr = Object.assign(new Error('invoice not found'), { code: 'P0002' }) + const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_not_found' }) + expect(res.status).toBe(404) + const body = await readEnvelope(res) + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.details).toMatchObject({ pgCode: 'P0002' }) + }) + it('falls back to INTERNAL_ERROR for unknown shapes', async () => { const res = errorResponse(new Error('boom'), noopLogger, { requestId: 'req_5' }) expect(res.status).toBe(500) diff --git a/lib/errors/get-structured-error.ts b/lib/errors/get-structured-error.ts index c64dd657..3b5b3b49 100644 --- a/lib/errors/get-structured-error.ts +++ b/lib/errors/get-structured-error.ts @@ -290,6 +290,7 @@ function postgresCodeToStructured(code: string): string | null { case '42501': return 'FORBIDDEN' case '42P01': + case 'P0002': return 'NOT_FOUND' case '40001': case '40P01': diff --git a/lib/invoices/__tests__/peppol-bis-billing.test.ts b/lib/invoices/__tests__/peppol-bis-billing.test.ts index 4ccac7c8..f28016b3 100644 --- a/lib/invoices/__tests__/peppol-bis-billing.test.ts +++ b/lib/invoices/__tests__/peppol-bis-billing.test.ts @@ -245,4 +245,35 @@ describe('generatePeppolBisBillingInvoice', () => { 'SUPPLIER_PARTICIPANT_IDENTIFIER_UNSUPPORTED', ])) }) + + it('rejects a personnummer-only buyer instead of labeling it as scheme 0007', () => { + const input = makeValidInput() + input.customer = makeCustomer({ ...input.customer, org_number: '800101-1231' }) + + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.issues.map(({ code }) => code)).toContain( + 'BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED', + ) + }) + + it('rejects credit notes and self-billed invoices in the generation layer', () => { + for (const invoice of [ + makeInvoice({ + ...makeValidInput().invoice, + credited_invoice_id: '22222222-2222-4222-8222-222222222222', + }), + makeInvoice({ ...makeValidInput().invoice, is_self_billed: true }), + ]) { + const input = makeValidInput() + input.invoice = invoice + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(false) + if (result.ok) continue + expect(result.issues.map(({ code }) => code)).toContain('DOCUMENT_TYPE_UNSUPPORTED') + } + }) }) diff --git a/lib/invoices/__tests__/peppol-delivery.test.ts b/lib/invoices/__tests__/peppol-delivery.test.ts new file mode 100644 index 00000000..7586b83d --- /dev/null +++ b/lib/invoices/__tests__/peppol-delivery.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { sha256Hex } from '../peppol-delivery' + +describe('sha256Hex', () => { + it('produces a stable lowercase fingerprint for the exact XML bytes', () => { + expect(sha256Hex('åäö')).toBe( + 'ab1c7c9e3a2780e73140e40a0af1a1d355026e3b754049bc6b0e8d03490b4d65', + ) + }) +}) diff --git a/lib/invoices/__tests__/peppol-transport.test.ts b/lib/invoices/__tests__/peppol-transport.test.ts new file mode 100644 index 00000000..df67cbc3 --- /dev/null +++ b/lib/invoices/__tests__/peppol-transport.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '@/lib/events' +import { + getPeppolTransport, + getPeppolTransportAvailability, + registerPeppolTransport, + type PeppolTransport, +} from '../peppol-transport' + +function makeTransport(provider: string): PeppolTransport { + return { + provider, + lookupRecipient: vi.fn(), + submit: vi.fn(), + verifyWebhook: vi.fn(), + retrieveEvidence: vi.fn(), + } +} + +describe('Peppol transport registry', () => { + const cleanups: Array<() => void> = [] + const originalProvider = process.env.PEPPOL_TRANSPORT_PROVIDER + + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + delete process.env.PEPPOL_TRANSPORT_PROVIDER + }) + + afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.() + if (originalProvider === undefined) { + delete process.env.PEPPOL_TRANSPORT_PROVIDER + } else { + process.env.PEPPOL_TRANSPORT_PROVIDER = originalProvider + } + }) + + it('stays truthfully unavailable until a provider is selected', () => { + expect(getPeppolTransportAvailability()).toEqual({ + available: false, + provider: null, + reason: 'provider_selection_required', + }) + }) + + it('does not claim availability for a configured but absent adapter', () => { + process.env.PEPPOL_TRANSPORT_PROVIDER = 'storecove' + + expect(getPeppolTransportAvailability()).toEqual({ + available: false, + provider: null, + reason: 'provider_adapter_unavailable', + }) + }) + + it('registers and removes an explicit adapter without a core default', () => { + const transport = makeTransport('Storecove') + cleanups.push(registerPeppolTransport(transport)) + process.env.PEPPOL_TRANSPORT_PROVIDER = 'storecove' + + expect(getPeppolTransport('STORECOVE')).toBe(transport) + expect(getPeppolTransportAvailability()).toEqual({ + available: true, + provider: 'storecove', + }) + }) + + it('rejects duplicate provider registrations', () => { + cleanups.push(registerPeppolTransport(makeTransport('qvalia'))) + expect(() => registerPeppolTransport(makeTransport('QVALIA'))) + .toThrow('Peppol transport already registered: qvalia') + }) +}) diff --git a/lib/invoices/peppol-bis-billing.ts b/lib/invoices/peppol-bis-billing.ts index 8dddb224..162bd3ca 100644 --- a/lib/invoices/peppol-bis-billing.ts +++ b/lib/invoices/peppol-bis-billing.ts @@ -38,7 +38,13 @@ export interface PeppolInvoiceInput { } export type PeppolInvoiceResult = - | { ok: true; xml: string; filename: string } + | { + ok: true + xml: string + filename: string + sender: { scheme: '0007'; identifier: string } + recipient: { scheme: '0007'; identifier: string } + } | { ok: false; issues: PeppolValidationIssue[] } interface PreparedParty { @@ -621,5 +627,7 @@ export function generatePeppolBisBillingInvoice(input: PeppolInvoiceInput): Pepp ok: true, xml: renderInvoiceXml(input, validation.prepared), filename: `peppol-invoice-${filenameNumber}.xml`, + sender: { scheme: '0007', identifier: validation.prepared.supplier.orgNumber }, + recipient: { scheme: '0007', identifier: validation.prepared.buyer.orgNumber }, } } diff --git a/lib/invoices/peppol-delivery.ts b/lib/invoices/peppol-delivery.ts new file mode 100644 index 00000000..59683011 --- /dev/null +++ b/lib/invoices/peppol-delivery.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto' +import type { SupabaseClient } from '@supabase/supabase-js' +import { + PEPPOL_BIS_BILLING_CUSTOMIZATION_ID, + PEPPOL_BIS_BILLING_PROFILE_ID, + type PeppolInvoiceResult, +} from '@/lib/invoices/peppol-bis-billing' +import type { + PeppolDeliveryEvidence, + PeppolVerifiedEvent, +} from '@/lib/invoices/peppol-transport' + +type GeneratedPeppolInvoice = Extract + +export interface PeppolDeliverySummary { + id: string + idempotency_key: string + recipient_scheme: string + recipient_identifier: string + xml_sha256: string + provider: string | null + provider_submission_id: string | null + status: string + status_at: string + status_detail: string | null + submitted_at: string | null + terminal_at: string | null + evidence_retrieved_at: string | null + created_at: string +} +export interface StagedPeppolDelivery extends PeppolDeliverySummary { + invoice_id: string + filename: string +} + +export function sha256Hex(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +export async function stagePeppolDelivery(args: { + supabase: SupabaseClient + companyId: string + invoiceId: string + document: GeneratedPeppolInvoice +}): Promise { + const { data, error } = await args.supabase.rpc('stage_peppol_delivery', { + p_company_id: args.companyId, + p_invoice_id: args.invoiceId, + p_recipient_scheme: args.document.recipient.scheme, + p_recipient_identifier: args.document.recipient.identifier, + p_customization_id: PEPPOL_BIS_BILLING_CUSTOMIZATION_ID, + p_profile_id: PEPPOL_BIS_BILLING_PROFILE_ID, + p_filename: args.document.filename, + p_xml_payload: args.document.xml, + p_xml_sha256: sha256Hex(args.document.xml), + }) + + if (error) throw error + if (!data) throw new Error('Failed to stage Peppol delivery: no data returned') + return data as StagedPeppolDelivery +} + +export async function listPeppolDeliverySummaries(args: { + supabase: SupabaseClient + companyId: string + invoiceId: string +}): Promise { + const { data, error } = await args.supabase.rpc('list_peppol_delivery_summaries', { + p_company_id: args.companyId, + p_invoice_id: args.invoiceId, + }) + if (error) { + throw new Error(`Failed to list Peppol deliveries: ${error.message}`) + } + return (data ?? []) as PeppolDeliverySummary[] +} + +export async function persistVerifiedPeppolEvent(args: { + supabase: SupabaseClient + companyId: string + event: PeppolVerifiedEvent +}): Promise { + const { event } = args + const { data, error } = await args.supabase.rpc('record_peppol_delivery_event', { + p_company_id: args.companyId, + p_idempotency_key: event.idempotencyKey, + p_provider: event.provider, + p_provider_tenant_id: event.providerTenantId, + p_provider_submission_id: event.providerSubmissionId, + p_provider_event_id: event.providerEventId, + p_provider_event_code: event.eventCode, + p_normalized_status: event.normalizedStatus, + p_is_terminal: event.isTerminal, + p_detail: event.detail, + p_raw_payload: event.rawPayload, + p_event_sha256: event.eventSha256, + p_verification_method: event.verificationMethod, + p_occurred_at: event.occurredAt, + }) + if (error || !data) { + throw new Error(`Failed to record Peppol event: ${error?.message ?? 'unknown error'}`) + } + return data as PeppolDeliverySummary +} + +export async function persistPeppolEvidence(args: { + supabase: SupabaseClient + companyId: string + idempotencyKey: string + evidence: PeppolDeliveryEvidence +}): Promise { + const { evidence } = args + const { data, error } = await args.supabase.rpc('record_peppol_delivery_evidence', { + p_company_id: args.companyId, + p_idempotency_key: args.idempotencyKey, + p_provider: evidence.provider, + p_evidence_type: evidence.evidenceType, + p_evidence_payload: evidence.payload, + p_document_payload: evidence.exactDocument, + p_document_sha256: evidence.exactDocumentSha256, + p_evidence_sha256: evidence.evidenceSha256, + p_retrieved_at: evidence.retrievedAt, + }) + if (error || !data) { + throw new Error(`Failed to record Peppol evidence: ${error?.message ?? 'unknown error'}`) + } + return data as string +} diff --git a/lib/invoices/peppol-transport.ts b/lib/invoices/peppol-transport.ts new file mode 100644 index 00000000..480de184 --- /dev/null +++ b/lib/invoices/peppol-transport.ts @@ -0,0 +1,141 @@ +/** + * Provider-neutral boundary between Accounted's Peppol lifecycle and a + * contracted Access Point. Core code must not infer network delivery from an + * HTTP response: submissions, asynchronous events, and evidence are distinct. + */ + +export type PeppolDeliveryStatus = + | 'staged' + | 'recipient_verified' + | 'submitting' + | 'retryable_failure' + | 'submission_accepted' + | 'transport_succeeded' + | 'recipient_acknowledged' + | 'business_accepted' + | 'business_rejected' + | 'no_route' + | 'failed' + +export interface PeppolParticipant { + scheme: string + identifier: string +} +export interface PeppolRecipientCapability { + documentTypeId: string + processId: string +} + +export type PeppolRecipientLookup = + | { + reachable: true + participant: PeppolParticipant + capabilities: PeppolRecipientCapability[] + checkedAt: string + } + | { + reachable: false + participant: PeppolParticipant + reasonCode: string + checkedAt: string + } + +export interface PeppolSubmission { + idempotencyKey: string + tenantReference: string + sender: PeppolParticipant + recipient: PeppolParticipant + documentTypeId: string + processId: string + filename: string + contentType: 'application/xml' + document: string + documentSha256: string +} + +export interface PeppolSubmissionReceipt { + provider: string + providerSubmissionId: string + idempotencyKey: string + tenantReference: string + acceptedAt: string +} + +export interface PeppolVerifiedEvent { + provider: string + providerTenantId: string | null + providerSubmissionId: string | null + providerEventId: string | null + idempotencyKey: string + eventCode: string + normalizedStatus: PeppolDeliveryStatus + isTerminal: boolean + detail: string | null + occurredAt: string + rawPayload: Record + eventSha256: string + verificationMethod: string +} + +export interface PeppolDeliveryEvidence { + provider: string + evidenceType: string + payload: Record + exactDocument: string | null + exactDocumentSha256: string | null + evidenceSha256: string + retrievedAt: string +} + +export interface PeppolWebhookRequest { + headers: Headers + rawBody: Uint8Array +} + +export interface PeppolTransport { + readonly provider: string + lookupRecipient(participant: PeppolParticipant): Promise + submit(submission: PeppolSubmission): Promise + verifyWebhook(request: PeppolWebhookRequest): Promise + retrieveEvidence(providerSubmissionId: string): Promise +} + +const transports = new Map() + +export function registerPeppolTransport(transport: PeppolTransport): () => void { + const provider = transport.provider.trim().toLowerCase() + if (!provider) throw new Error('Peppol transport provider is required') + if (transports.has(provider)) { + throw new Error(`Peppol transport already registered: ${provider}`) + } + + transports.set(provider, transport) + return () => { + if (transports.get(provider) === transport) transports.delete(provider) + } +} + +export function getPeppolTransport(provider: string): PeppolTransport | null { + return transports.get(provider.trim().toLowerCase()) ?? null +} + +export type PeppolTransportAvailability = + | { available: true; provider: string } + | { + available: false + provider: null + reason: 'provider_selection_required' | 'provider_adapter_unavailable' + } + +export function getPeppolTransportAvailability(): PeppolTransportAvailability { + const configuredProvider = process.env.PEPPOL_TRANSPORT_PROVIDER?.trim().toLowerCase() + if (!configuredProvider) { + return { available: false, provider: null, reason: 'provider_selection_required' } + } + + if (!transports.has(configuredProvider)) { + return { available: false, provider: null, reason: 'provider_adapter_unavailable' } + } + + return { available: true, provider: configuredProvider } +} diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 5e87121f..c49a8c7e 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -840,6 +840,15 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [ // Delivery metadata proves which recipient received the archived PDF and // when, so it is räkenskapsinformation alongside the invoice itself. { name: 'invoice_deliveries', file: 'invoice_deliveries.json', orderBy: 'created_at' }, + // Peppol archive evidence is split so the exact staged UBL, every verified + // asynchronous event, and provider evidence stay independently auditable. + { name: 'peppol_deliveries', file: 'peppol_deliveries.json', orderBy: 'created_at' }, + { name: 'peppol_delivery_events', file: 'peppol_delivery_events.json', orderBy: 'created_at' }, + { + name: 'peppol_delivery_evidence', + file: 'peppol_delivery_evidence.json', + orderBy: 'created_at', + }, { name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' }, // Supplier invoicing { name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' }, diff --git a/messages/en.json b/messages/en.json index 73487616..fe2de335 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3666,6 +3666,9 @@ "copy_invoice": "Copy invoice", "download_pdf": "Download PDF", "download_peppol_xml": "Download Peppol XML", + "prepare_peppol_delivery": "Prepare Peppol delivery", + "send_via_peppol": "Send via Peppol", + "peppol_provider_required": "A contracted and configured Peppol provider is required before Accounted can send.", "preview_pdf": "Preview", "viewer_disabled_tooltip": "You only have read-only access to this company", "customer_card_title": "Customer", @@ -3789,6 +3792,10 @@ "peppol_downloaded_description": "The XML file passed Accounted's local checks. It has not been sent to Peppol.", "peppol_download_failed_title": "Could not create Peppol XML", "peppol_download_failed_description": "The invoice does not satisfy the Peppol profile supported by this export.", + "peppol_prepared_title": "Peppol delivery prepared", + "peppol_prepared_description": "An immutable XML copy was saved for traceability. The invoice has not been sent to Peppol.", + "peppol_prepare_failed_title": "Could not prepare Peppol delivery", + "peppol_prepare_failed_description": "The invoice could not be saved as a prepared Peppol delivery.", "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 570abf6d..d90c4d2a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3666,6 +3666,9 @@ "copy_invoice": "Kopiera faktura", "download_pdf": "Ladda ner PDF", "download_peppol_xml": "Ladda ner Peppol XML", + "prepare_peppol_delivery": "Förbered Peppol-leverans", + "send_via_peppol": "Skicka via Peppol", + "peppol_provider_required": "En avtalad och konfigurerad Peppol-operatör krävs innan Accounted kan skicka.", "preview_pdf": "Förhandsgranska", "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag", "customer_card_title": "Kund", @@ -3789,6 +3792,10 @@ "peppol_downloaded_description": "XML-filen har klarat Accounteds lokala kontroll. Den har inte skickats till Peppol.", "peppol_download_failed_title": "Kunde inte skapa Peppol XML", "peppol_download_failed_description": "Fakturan uppfyller inte den Peppol-profil som exporten stöder.", + "peppol_prepared_title": "Peppol-leverans förberedd", + "peppol_prepared_description": "En oföränderlig XML-kopia har sparats för spårbarhet. Fakturan har inte skickats till Peppol.", + "peppol_prepare_failed_title": "Kunde inte förbereda Peppol-leveransen", + "peppol_prepare_failed_description": "Fakturan kunde inte sparas som en förberedd Peppol-leverans.", "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/supabase/migrations/20260813162752_peppol_delivery_lifecycle.sql b/supabase/migrations/20260813162752_peppol_delivery_lifecycle.sql new file mode 100644 index 00000000..72bf4357 --- /dev/null +++ b/supabase/migrations/20260813162752_peppol_delivery_lifecycle.sql @@ -0,0 +1,713 @@ +-- Provider-neutral Peppol delivery staging and audit foundation. +-- +-- This migration deliberately does not implement network delivery. A certified +-- Access Point contract, participant onboarding, credentials, and verified +-- webhook contract are still required. The tables and RPCs preserve the exact +-- staged UBL document, a stable provider idempotency key, every verified event, +-- and retrieved evidence without equating Corner 3 transport with buyer +-- acceptance. + +CREATE TABLE public.peppol_deliveries ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT, + idempotency_key uuid NOT NULL DEFAULT uuid_generate_v4(), + recipient_scheme text NOT NULL, + recipient_identifier text NOT NULL, + customization_id text NOT NULL, + profile_id text NOT NULL, + filename text NOT NULL, + xml_payload text NOT NULL, + xml_sha256 text NOT NULL, + provider text, + provider_tenant_id text, + provider_submission_id text, + status text NOT NULL DEFAULT 'staged', + status_at timestamptz NOT NULL DEFAULT now(), + status_detail text, + submitted_at timestamptz, + terminal_at timestamptz, + evidence_retrieved_at timestamptz, + retention_expires_at date NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT peppol_deliveries_recipient_scheme_format + CHECK (recipient_scheme ~ '^[0-9]{4}$'), + CONSTRAINT peppol_deliveries_recipient_identifier_format + CHECK (recipient_identifier = btrim(recipient_identifier) + AND length(recipient_identifier) BETWEEN 1 AND 128), + CONSTRAINT peppol_deliveries_xml_sha256_format + CHECK (xml_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT peppol_deliveries_payload_present + CHECK (length(xml_payload) > 0 AND length(filename) BETWEEN 1 AND 255), + CONSTRAINT peppol_deliveries_provider_shape + CHECK ( + (provider IS NULL AND provider_tenant_id IS NULL AND provider_submission_id IS NULL) + OR (provider IS NOT NULL AND length(btrim(provider)) BETWEEN 1 AND 64) + ), + CONSTRAINT peppol_deliveries_status_check CHECK (status IN ( + 'staged', + 'recipient_verified', + 'submitting', + 'retryable_failure', + 'submission_accepted', + 'transport_succeeded', + 'recipient_acknowledged', + 'business_accepted', + 'business_rejected', + 'no_route', + 'failed' + )), + CONSTRAINT peppol_deliveries_terminal_shape CHECK ( + terminal_at IS NULL + OR status IN ('business_accepted', 'business_rejected', 'no_route', 'failed') + ), + CONSTRAINT peppol_deliveries_submission_shape CHECK ( + submitted_at IS NULL OR provider IS NOT NULL + ) +); + +CREATE UNIQUE INDEX idx_peppol_deliveries_company_idempotency + ON public.peppol_deliveries (company_id, idempotency_key); +CREATE UNIQUE INDEX idx_peppol_deliveries_staged_document + ON public.peppol_deliveries (company_id, invoice_id, xml_sha256); +CREATE UNIQUE INDEX idx_peppol_deliveries_provider_submission + ON public.peppol_deliveries (provider, provider_submission_id) + WHERE provider IS NOT NULL AND provider_submission_id IS NOT NULL; +CREATE INDEX idx_peppol_deliveries_invoice_created + ON public.peppol_deliveries (company_id, invoice_id, created_at DESC); + +CREATE TABLE public.peppol_delivery_events ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + delivery_id uuid NOT NULL REFERENCES public.peppol_deliveries(id) ON DELETE RESTRICT, + source text NOT NULL, + provider text, + provider_event_id text, + provider_event_code text NOT NULL, + normalized_status text NOT NULL, + is_terminal boolean NOT NULL DEFAULT false, + detail text, + raw_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + event_sha256 text NOT NULL, + verification_method text NOT NULL, + occurred_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT peppol_delivery_events_source_check + CHECK (source IN ('local', 'provider')), + CONSTRAINT peppol_delivery_events_status_check CHECK (normalized_status IN ( + 'staged', + 'recipient_verified', + 'submitting', + 'retryable_failure', + 'submission_accepted', + 'transport_succeeded', + 'recipient_acknowledged', + 'business_accepted', + 'business_rejected', + 'no_route', + 'failed' + )), + CONSTRAINT peppol_delivery_events_terminal_status CHECK ( + NOT is_terminal + OR normalized_status IN ('business_accepted', 'business_rejected', 'no_route', 'failed') + ), + CONSTRAINT peppol_delivery_events_raw_payload_object + CHECK (jsonb_typeof(raw_payload) = 'object'), + CONSTRAINT peppol_delivery_events_sha256_format + CHECK (event_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT peppol_delivery_events_verification_present + CHECK (length(btrim(verification_method)) BETWEEN 1 AND 64), + CONSTRAINT peppol_delivery_events_provider_shape CHECK ( + (source = 'local' AND provider IS NULL) + OR (source = 'provider' AND provider IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_peppol_delivery_events_fingerprint + ON public.peppol_delivery_events (delivery_id, event_sha256); +CREATE UNIQUE INDEX idx_peppol_delivery_events_provider_event + ON public.peppol_delivery_events (provider, provider_event_id) + WHERE provider IS NOT NULL AND provider_event_id IS NOT NULL; +CREATE INDEX idx_peppol_delivery_events_delivery_received + ON public.peppol_delivery_events (delivery_id, received_at); + +CREATE TABLE public.peppol_delivery_evidence ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + delivery_id uuid NOT NULL REFERENCES public.peppol_deliveries(id) ON DELETE RESTRICT, + provider text NOT NULL, + evidence_type text NOT NULL, + evidence_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + document_payload text, + document_sha256 text, + evidence_sha256 text NOT NULL, + retrieved_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT peppol_delivery_evidence_provider_present + CHECK (length(btrim(provider)) BETWEEN 1 AND 64), + CONSTRAINT peppol_delivery_evidence_type_present + CHECK (length(btrim(evidence_type)) BETWEEN 1 AND 128), + CONSTRAINT peppol_delivery_evidence_payload_object + CHECK (jsonb_typeof(evidence_payload) = 'object'), + CONSTRAINT peppol_delivery_evidence_sha256_format + CHECK (evidence_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT peppol_delivery_evidence_document_sha256_format + CHECK (document_sha256 IS NULL OR document_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT peppol_delivery_evidence_document_shape CHECK ( + (document_payload IS NULL AND document_sha256 IS NULL) + OR (document_payload IS NOT NULL AND document_sha256 IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_peppol_delivery_evidence_fingerprint + ON public.peppol_delivery_evidence (delivery_id, evidence_sha256); +CREATE INDEX idx_peppol_delivery_evidence_delivery_retrieved + ON public.peppol_delivery_evidence (delivery_id, retrieved_at); + +ALTER TABLE public.peppol_deliveries ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.peppol_delivery_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.peppol_delivery_evidence ENABLE ROW LEVEL SECURITY; + +-- Policies express tenant ownership, but direct table grants stay revoked so +-- the browser cannot read immutable XML, raw webhooks, or provider evidence. +-- User-facing reads go through the minimized summary RPC below. +CREATE POLICY "view own-company peppol deliveries" + ON public.peppol_deliveries FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "view own-company peppol delivery events" + ON public.peppol_delivery_events FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "view own-company peppol delivery evidence" + ON public.peppol_delivery_evidence FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +REVOKE ALL ON public.peppol_deliveries FROM PUBLIC, anon, authenticated; +REVOKE ALL ON public.peppol_delivery_events FROM PUBLIC, anon, authenticated; +REVOKE ALL ON public.peppol_delivery_evidence FROM PUBLIC, anon, authenticated; + +CREATE OR REPLACE FUNCTION public.peppol_delivery_status_rank(p_status text) +RETURNS integer +LANGUAGE sql +IMMUTABLE +SET search_path = pg_catalog, public +AS $$ + SELECT CASE p_status + WHEN 'staged' THEN 0 + WHEN 'recipient_verified' THEN 5 + WHEN 'submitting' THEN 10 + WHEN 'retryable_failure' THEN 15 + WHEN 'submission_accepted' THEN 20 + WHEN 'transport_succeeded' THEN 30 + WHEN 'recipient_acknowledged' THEN 40 + WHEN 'business_accepted' THEN 50 + WHEN 'business_rejected' THEN 50 + WHEN 'no_route' THEN 50 + WHEN 'failed' THEN 50 + ELSE -1 + END +$$; + +REVOKE ALL ON FUNCTION public.peppol_delivery_status_rank(text) + FROM PUBLIC, anon, authenticated; + +CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_immutability() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Peppol delivery records are retained and cannot be deleted' + USING ERRCODE = '23514'; + END IF; + + IF current_setting('accounted.peppol_projection_update', true) IS DISTINCT FROM '1' THEN + RAISE EXCEPTION 'Peppol delivery state may only change through its event RPC' + USING ERRCODE = '42501'; + END IF; + + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id + OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key + OR NEW.recipient_scheme IS DISTINCT FROM OLD.recipient_scheme + OR NEW.recipient_identifier IS DISTINCT FROM OLD.recipient_identifier + OR NEW.customization_id IS DISTINCT FROM OLD.customization_id + OR NEW.profile_id IS DISTINCT FROM OLD.profile_id + OR NEW.filename IS DISTINCT FROM OLD.filename + OR NEW.xml_payload IS DISTINCT FROM OLD.xml_payload + OR NEW.xml_sha256 IS DISTINCT FROM OLD.xml_sha256 + OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'Peppol staged document and tenant identity are immutable' + USING ERRCODE = '23514'; + END IF; + + IF OLD.provider IS NOT NULL AND NEW.provider IS DISTINCT FROM OLD.provider THEN + RAISE EXCEPTION 'Peppol provider cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + IF OLD.provider_tenant_id IS NOT NULL + AND NEW.provider_tenant_id IS DISTINCT FROM OLD.provider_tenant_id + THEN + RAISE EXCEPTION 'Peppol provider tenant cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + IF OLD.provider_submission_id IS NOT NULL + AND NEW.provider_submission_id IS DISTINCT FROM OLD.provider_submission_id + THEN + RAISE EXCEPTION 'Peppol provider submission cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_peppol_delivery_immutability + BEFORE UPDATE OR DELETE ON public.peppol_deliveries + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_immutability(); + +CREATE OR REPLACE FUNCTION public.enforce_peppol_append_only() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + RAISE EXCEPTION 'Peppol audit records are append-only' + USING ERRCODE = '23514'; +END; +$$; + +CREATE TRIGGER enforce_peppol_delivery_events_append_only + BEFORE UPDATE OR DELETE ON public.peppol_delivery_events + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_append_only(); +CREATE TRIGGER enforce_peppol_delivery_evidence_append_only + BEFORE UPDATE OR DELETE ON public.peppol_delivery_evidence + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_append_only(); + +CREATE OR REPLACE FUNCTION public.stage_peppol_delivery( + p_company_id uuid, + p_invoice_id uuid, + p_recipient_scheme text, + p_recipient_identifier text, + p_customization_id text, + p_profile_id text, + p_filename text, + p_xml_payload text, + p_xml_sha256 text +) +RETURNS public.peppol_deliveries +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + actor_id uuid := auth.uid(); + invoice_date date; + invoice_owner uuid; + retention_basis date; + staged public.peppol_deliveries%ROWTYPE; +BEGIN + IF actor_id IS NULL OR NOT EXISTS ( + SELECT 1 + FROM public.company_members AS member + WHERE member.company_id = p_company_id + AND member.user_id = actor_id + AND member.role <> 'viewer' + ) THEN + RAISE EXCEPTION 'not authorized to stage Peppol delivery' + USING ERRCODE = '42501'; + END IF; + + SELECT invoice.invoice_date, invoice.user_id + INTO invoice_date, invoice_owner + FROM public.invoices AS invoice + WHERE invoice.id = p_invoice_id + AND invoice.company_id = p_company_id + AND invoice.invoice_number IS NOT NULL + AND invoice.status <> 'cancelled'; + + IF invoice_date IS NULL THEN + RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging' + USING ERRCODE = 'P0002'; + END IF; + + SELECT period.period_end + INTO retention_basis + FROM public.fiscal_periods AS period + WHERE period.company_id = p_company_id + AND invoice_date BETWEEN period.period_start AND period.period_end + ORDER BY period.period_end DESC + LIMIT 1; + retention_basis := COALESCE(retention_basis, invoice_date); + + SELECT * INTO staged + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.invoice_id = p_invoice_id + AND delivery.xml_sha256 = p_xml_sha256; + IF FOUND THEN + RETURN staged; + END IF; + + INSERT INTO public.peppol_deliveries ( + company_id, user_id, invoice_id, + recipient_scheme, recipient_identifier, customization_id, profile_id, + filename, xml_payload, xml_sha256, retention_expires_at + ) VALUES ( + p_company_id, actor_id, p_invoice_id, + p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id, + p_filename, p_xml_payload, lower(p_xml_sha256), + (date_trunc('year', retention_basis)::date + interval '8 years')::date + ) + RETURNING * INTO staged; + + INSERT INTO public.peppol_delivery_events ( + company_id, delivery_id, source, provider_event_code, normalized_status, + raw_payload, event_sha256, verification_method, occurred_at + ) VALUES ( + p_company_id, staged.id, 'local', 'staged', 'staged', + jsonb_build_object( + 'invoice_id', p_invoice_id, + 'idempotency_key', staged.idempotency_key, + 'xml_sha256', staged.xml_sha256 + ), + staged.xml_sha256, + 'local', + staged.created_at + ); + + RETURN staged; +END; +$$; + +REVOKE ALL ON FUNCTION public.stage_peppol_delivery( + uuid, uuid, text, text, text, text, text, text, text +) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.stage_peppol_delivery( + uuid, uuid, text, text, text, text, text, text, text +) TO authenticated; + +CREATE OR REPLACE FUNCTION public.record_peppol_delivery_event( + p_company_id uuid, + p_idempotency_key uuid, + p_provider text, + p_provider_tenant_id text, + p_provider_submission_id text, + p_provider_event_id text, + p_provider_event_code text, + p_normalized_status text, + p_is_terminal boolean, + p_detail text, + p_raw_payload jsonb, + p_event_sha256 text, + p_verification_method text, + p_occurred_at timestamptz +) +RETURNS public.peppol_deliveries +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + target public.peppol_deliveries%ROWTYPE; + inserted_count integer := 0; + next_rank integer; + current_rank integer; +BEGIN + IF COALESCE(current_setting('request.jwt.claim.role', true), '') <> 'service_role' + AND COALESCE(current_setting('request.jwt.claims', true), '{}')::jsonb ->> 'role' + IS DISTINCT FROM 'service_role' + THEN + RAISE EXCEPTION 'Peppol provider events require service role' + USING ERRCODE = '42501'; + END IF; + + IF p_normalized_status NOT IN ( + 'staged', 'recipient_verified', 'submitting', 'retryable_failure', + 'submission_accepted', 'transport_succeeded', 'recipient_acknowledged', + 'business_accepted', 'business_rejected', 'no_route', 'failed' + ) THEN + RAISE EXCEPTION 'unsupported normalized Peppol status' + USING ERRCODE = '22023'; + END IF; + + SELECT * INTO target + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.idempotency_key = p_idempotency_key + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'Peppol delivery not found' + USING ERRCODE = 'P0002'; + END IF; + + IF target.provider IS NOT NULL AND target.provider IS DISTINCT FROM p_provider THEN + RAISE EXCEPTION 'Peppol provider correlation mismatch' + USING ERRCODE = '23514'; + END IF; + IF target.provider_tenant_id IS NOT NULL + AND target.provider_tenant_id IS DISTINCT FROM p_provider_tenant_id + THEN + RAISE EXCEPTION 'Peppol provider tenant correlation mismatch' + USING ERRCODE = '23514'; + END IF; + IF target.provider_submission_id IS NOT NULL + AND p_provider_submission_id IS NOT NULL + AND target.provider_submission_id IS DISTINCT FROM p_provider_submission_id + THEN + RAISE EXCEPTION 'Peppol submission correlation mismatch' + USING ERRCODE = '23514'; + END IF; + + INSERT INTO public.peppol_delivery_events ( + company_id, delivery_id, source, provider, provider_event_id, + provider_event_code, normalized_status, is_terminal, detail, raw_payload, + event_sha256, verification_method, occurred_at + ) VALUES ( + p_company_id, target.id, 'provider', p_provider, p_provider_event_id, + p_provider_event_code, p_normalized_status, p_is_terminal, p_detail, + COALESCE(p_raw_payload, '{}'::jsonb), lower(p_event_sha256), + p_verification_method, COALESCE(p_occurred_at, now()) + ) + ON CONFLICT DO NOTHING; + GET DIAGNOSTICS inserted_count = ROW_COUNT; + + IF inserted_count = 0 THEN + RETURN target; + END IF; + + next_rank := public.peppol_delivery_status_rank(p_normalized_status); + current_rank := public.peppol_delivery_status_rank(target.status); + + PERFORM set_config('accounted.peppol_projection_update', '1', true); + UPDATE public.peppol_deliveries AS delivery + SET provider = COALESCE(delivery.provider, p_provider), + provider_tenant_id = COALESCE(delivery.provider_tenant_id, p_provider_tenant_id), + provider_submission_id = COALESCE( + delivery.provider_submission_id, + p_provider_submission_id + ), + status = CASE + WHEN delivery.terminal_at IS NOT NULL THEN delivery.status + WHEN next_rank > current_rank THEN p_normalized_status + WHEN delivery.status = 'retryable_failure' + AND p_normalized_status IN ('submitting', 'submission_accepted') + THEN p_normalized_status + WHEN next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at + THEN p_normalized_status + ELSE delivery.status + END, + status_at = CASE + WHEN delivery.terminal_at IS NOT NULL THEN delivery.status_at + WHEN next_rank > current_rank + OR (delivery.status = 'retryable_failure' + AND p_normalized_status IN ('submitting', 'submission_accepted')) + OR (next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at) + THEN COALESCE(p_occurred_at, now()) + ELSE delivery.status_at + END, + status_detail = CASE + WHEN delivery.terminal_at IS NULL AND ( + next_rank > current_rank + OR (delivery.status = 'retryable_failure' + AND p_normalized_status IN ('submitting', 'submission_accepted')) + OR (next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at) + ) THEN p_detail + ELSE delivery.status_detail + END, + submitted_at = CASE + WHEN p_normalized_status IN ( + 'submission_accepted', 'transport_succeeded', 'recipient_acknowledged', + 'business_accepted', 'business_rejected' + ) THEN COALESCE(delivery.submitted_at, COALESCE(p_occurred_at, now())) + ELSE delivery.submitted_at + END, + terminal_at = CASE + WHEN delivery.terminal_at IS NULL AND p_is_terminal + THEN COALESCE(p_occurred_at, now()) + ELSE delivery.terminal_at + END, + updated_at = now() + WHERE delivery.id = target.id + RETURNING * INTO target; + + RETURN target; +END; +$$; + +REVOKE ALL ON FUNCTION public.record_peppol_delivery_event( + uuid, uuid, text, text, text, text, text, text, boolean, text, jsonb, + text, text, timestamptz +) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.record_peppol_delivery_event( + uuid, uuid, text, text, text, text, text, text, boolean, text, jsonb, + text, text, timestamptz +) TO service_role; + +CREATE OR REPLACE FUNCTION public.record_peppol_delivery_evidence( + p_company_id uuid, + p_idempotency_key uuid, + p_provider text, + p_evidence_type text, + p_evidence_payload jsonb, + p_document_payload text, + p_document_sha256 text, + p_evidence_sha256 text, + p_retrieved_at timestamptz +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + target public.peppol_deliveries%ROWTYPE; + evidence_id uuid; +BEGIN + IF COALESCE(current_setting('request.jwt.claim.role', true), '') <> 'service_role' + AND COALESCE(current_setting('request.jwt.claims', true), '{}')::jsonb ->> 'role' + IS DISTINCT FROM 'service_role' + THEN + RAISE EXCEPTION 'Peppol evidence writes require service role' + USING ERRCODE = '42501'; + END IF; + + SELECT * INTO target + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.idempotency_key = p_idempotency_key + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'Peppol delivery not found' + USING ERRCODE = 'P0002'; + END IF; + IF target.provider IS DISTINCT FROM p_provider THEN + RAISE EXCEPTION 'Peppol evidence provider correlation mismatch' + USING ERRCODE = '23514'; + END IF; + + INSERT INTO public.peppol_delivery_evidence ( + company_id, delivery_id, provider, evidence_type, evidence_payload, + document_payload, document_sha256, evidence_sha256, retrieved_at + ) VALUES ( + p_company_id, target.id, p_provider, p_evidence_type, + COALESCE(p_evidence_payload, '{}'::jsonb), p_document_payload, + lower(p_document_sha256), lower(p_evidence_sha256), + COALESCE(p_retrieved_at, now()) + ) + ON CONFLICT (delivery_id, evidence_sha256) DO NOTHING + RETURNING id INTO evidence_id; + + IF evidence_id IS NULL THEN + SELECT evidence.id INTO evidence_id + FROM public.peppol_delivery_evidence AS evidence + WHERE evidence.delivery_id = target.id + AND evidence.evidence_sha256 = lower(p_evidence_sha256); + END IF; + + PERFORM set_config('accounted.peppol_projection_update', '1', true); + UPDATE public.peppol_deliveries + SET evidence_retrieved_at = GREATEST( + COALESCE(evidence_retrieved_at, '-infinity'::timestamptz), + COALESCE(p_retrieved_at, now()) + ), + updated_at = now() + WHERE id = target.id; + + RETURN evidence_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.record_peppol_delivery_evidence( + uuid, uuid, text, text, jsonb, text, text, text, timestamptz +) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.record_peppol_delivery_evidence( + uuid, uuid, text, text, jsonb, text, text, text, timestamptz +) TO service_role; + +CREATE OR REPLACE FUNCTION public.list_peppol_delivery_summaries( + p_company_id uuid, + p_invoice_id uuid +) +RETURNS TABLE ( + id uuid, + idempotency_key uuid, + recipient_scheme text, + recipient_identifier text, + xml_sha256 text, + provider text, + provider_submission_id text, + status text, + status_at timestamptz, + status_detail text, + submitted_at timestamptz, + terminal_at timestamptz, + evidence_retrieved_at timestamptz, + created_at timestamptz +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF auth.uid() IS NULL OR NOT EXISTS ( + SELECT 1 + FROM public.company_members AS member + WHERE member.company_id = p_company_id + AND member.user_id = auth.uid() + ) THEN + RAISE EXCEPTION 'not authorized to list Peppol delivery summaries' + USING ERRCODE = '42501'; + END IF; + + RETURN QUERY + SELECT + delivery.id, + delivery.idempotency_key, + delivery.recipient_scheme, + delivery.recipient_identifier, + delivery.xml_sha256, + delivery.provider, + delivery.provider_submission_id, + delivery.status, + delivery.status_at, + delivery.status_detail, + delivery.submitted_at, + delivery.terminal_at, + delivery.evidence_retrieved_at, + delivery.created_at + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.invoice_id = p_invoice_id + ORDER BY delivery.created_at DESC; +END; +$$; + +REVOKE ALL ON FUNCTION public.list_peppol_delivery_summaries(uuid, uuid) + FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.list_peppol_delivery_summaries(uuid, uuid) + TO authenticated; + +COMMENT ON TABLE public.peppol_deliveries IS + 'Immutable staged Peppol BIS Billing documents plus a projection of verified delivery events. Staged does not mean sent; transport_succeeded does not mean buyer acceptance.'; +COMMENT ON TABLE public.peppol_delivery_events IS + 'Append-only normalized and raw verified Peppol provider events. All events remain evidence even when they arrive out of order and do not change the delivery projection.'; +COMMENT ON TABLE public.peppol_delivery_evidence IS + 'Append-only Access Point evidence snapshots, optionally including the exact transmitted document returned by the provider.'; +COMMENT ON COLUMN public.peppol_deliveries.idempotency_key IS + 'Stable caller-supplied key for a future provider submit call. Reused for retries of this exact staged XML.'; +COMMENT ON COLUMN public.peppol_deliveries.xml_payload IS + 'Exact UBL XML staged for submission. Immutable and excluded from user-facing summary RPCs.'; +COMMENT ON COLUMN public.peppol_deliveries.status IS + 'Latest monotonic normalized projection. Raw events remain authoritative audit evidence; transport_succeeded is only Corner 3 transport.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813163034_peppol_delivery_hardening.sql b/supabase/migrations/20260813163034_peppol_delivery_hardening.sql new file mode 100644 index 00000000..17483471 --- /dev/null +++ b/supabase/migrations/20260813163034_peppol_delivery_hardening.sql @@ -0,0 +1,196 @@ +-- Hardening found by staging advisors and concurrency review. + +CREATE INDEX idx_peppol_deliveries_invoice_id + ON public.peppol_deliveries (invoice_id); +CREATE INDEX idx_peppol_deliveries_user_id + ON public.peppol_deliveries (user_id); +CREATE INDEX idx_peppol_delivery_events_company_id + ON public.peppol_delivery_events (company_id); +CREATE INDEX idx_peppol_delivery_evidence_company_id + ON public.peppol_delivery_evidence (company_id); + +DROP POLICY "view own-company peppol deliveries" ON public.peppol_deliveries; +CREATE POLICY "view own-company peppol deliveries" + ON public.peppol_deliveries FOR SELECT TO authenticated + USING (company_id IN (SELECT public.user_company_ids())); + +DROP POLICY "view own-company peppol delivery events" ON public.peppol_delivery_events; +CREATE POLICY "view own-company peppol delivery events" + ON public.peppol_delivery_events FOR SELECT TO authenticated + USING (company_id IN (SELECT public.user_company_ids())); + +DROP POLICY "view own-company peppol delivery evidence" ON public.peppol_delivery_evidence; +CREATE POLICY "view own-company peppol delivery evidence" + ON public.peppol_delivery_evidence FOR SELECT TO authenticated + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_immutability() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Peppol delivery records are retained and cannot be deleted' + USING ERRCODE = '23514'; + END IF; + + IF current_setting('accounted.peppol_projection_update', true) IS DISTINCT FROM '1' THEN + RAISE EXCEPTION 'Peppol delivery state may only change through its event RPC' + USING ERRCODE = '42501'; + END IF; + + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id + OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key + OR NEW.recipient_scheme IS DISTINCT FROM OLD.recipient_scheme + OR NEW.recipient_identifier IS DISTINCT FROM OLD.recipient_identifier + OR NEW.customization_id IS DISTINCT FROM OLD.customization_id + OR NEW.profile_id IS DISTINCT FROM OLD.profile_id + OR NEW.filename IS DISTINCT FROM OLD.filename + OR NEW.xml_payload IS DISTINCT FROM OLD.xml_payload + OR NEW.xml_sha256 IS DISTINCT FROM OLD.xml_sha256 + OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'Peppol staged document and tenant identity are immutable' + USING ERRCODE = '23514'; + END IF; + + IF OLD.provider IS NOT NULL AND NEW.provider IS DISTINCT FROM OLD.provider THEN + RAISE EXCEPTION 'Peppol provider cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + IF OLD.provider_tenant_id IS NOT NULL + AND NEW.provider_tenant_id IS DISTINCT FROM OLD.provider_tenant_id + THEN + RAISE EXCEPTION 'Peppol provider tenant cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + IF OLD.provider_submission_id IS NOT NULL + AND NEW.provider_submission_id IS DISTINCT FROM OLD.provider_submission_id + THEN + RAISE EXCEPTION 'Peppol provider submission cannot change on an existing delivery' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.enforce_peppol_append_only() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = pg_catalog, public +AS $$ +BEGIN + RAISE EXCEPTION 'Peppol audit records are append-only' + USING ERRCODE = '23514'; +END; +$$; + +REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_immutability() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.enforce_peppol_append_only() + FROM PUBLIC, anon, authenticated; + +CREATE OR REPLACE FUNCTION public.stage_peppol_delivery( + p_company_id uuid, + p_invoice_id uuid, + p_recipient_scheme text, + p_recipient_identifier text, + p_customization_id text, + p_profile_id text, + p_filename text, + p_xml_payload text, + p_xml_sha256 text +) +RETURNS public.peppol_deliveries +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + actor_id uuid := auth.uid(); + invoice_date date; + retention_basis date; + staged public.peppol_deliveries%ROWTYPE; +BEGIN + IF actor_id IS NULL OR NOT EXISTS ( + SELECT 1 + FROM public.company_members AS member + WHERE member.company_id = p_company_id + AND member.user_id = actor_id + AND member.role <> 'viewer' + ) THEN + RAISE EXCEPTION 'not authorized to stage Peppol delivery' + USING ERRCODE = '42501'; + END IF; + + SELECT invoice.invoice_date + INTO invoice_date + FROM public.invoices AS invoice + WHERE invoice.id = p_invoice_id + AND invoice.company_id = p_company_id + AND invoice.invoice_number IS NOT NULL + AND invoice.status <> 'cancelled'; + + IF invoice_date IS NULL THEN + RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging' + USING ERRCODE = 'P0002'; + END IF; + + SELECT period.period_end + INTO retention_basis + FROM public.fiscal_periods AS period + WHERE period.company_id = p_company_id + AND invoice_date BETWEEN period.period_start AND period.period_end + ORDER BY period.period_end DESC + LIMIT 1; + retention_basis := COALESCE(retention_basis, invoice_date); + + INSERT INTO public.peppol_deliveries ( + company_id, user_id, invoice_id, + recipient_scheme, recipient_identifier, customization_id, profile_id, + filename, xml_payload, xml_sha256, retention_expires_at + ) VALUES ( + p_company_id, actor_id, p_invoice_id, + p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id, + p_filename, p_xml_payload, lower(p_xml_sha256), + (date_trunc('year', retention_basis)::date + interval '8 years')::date + ) + ON CONFLICT (company_id, invoice_id, xml_sha256) DO NOTHING + RETURNING * INTO staged; + + IF staged.id IS NULL THEN + SELECT * INTO STRICT staged + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.invoice_id = p_invoice_id + AND delivery.xml_sha256 = lower(p_xml_sha256); + RETURN staged; + END IF; + + INSERT INTO public.peppol_delivery_events ( + company_id, delivery_id, source, provider_event_code, normalized_status, + raw_payload, event_sha256, verification_method, occurred_at + ) VALUES ( + p_company_id, staged.id, 'local', 'staged', 'staged', + jsonb_build_object( + 'invoice_id', p_invoice_id, + 'idempotency_key', staged.idempotency_key, + 'xml_sha256', staged.xml_sha256 + ), + staged.xml_sha256, + 'local', + staged.created_at + ); + + RETURN staged; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813164931_peppol_delivery_payload_hash_guards.sql b/supabase/migrations/20260813164931_peppol_delivery_payload_hash_guards.sql new file mode 100644 index 00000000..a099ba24 --- /dev/null +++ b/supabase/migrations/20260813164931_peppol_delivery_payload_hash_guards.sql @@ -0,0 +1,54 @@ +-- Ensure immutable Peppol documents are cryptographically bound to their +-- stored SHA-256 values even when an RPC is called outside the application. + +CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_payload_hash() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = pg_catalog, public, extensions +AS $$ +BEGIN + IF encode(extensions.digest(NEW.xml_payload, 'sha256'), 'hex') + IS DISTINCT FROM NEW.xml_sha256 + THEN + RAISE EXCEPTION 'Peppol XML SHA-256 does not match the staged payload' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_peppol_delivery_payload_hash + BEFORE INSERT ON public.peppol_deliveries + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_payload_hash(); + +CREATE OR REPLACE FUNCTION public.enforce_peppol_evidence_document_hash() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = pg_catalog, public, extensions +AS $$ +BEGIN + IF NEW.document_payload IS NOT NULL + AND encode(extensions.digest(NEW.document_payload, 'sha256'), 'hex') + IS DISTINCT FROM NEW.document_sha256 + THEN + RAISE EXCEPTION 'Peppol evidence document SHA-256 does not match the payload' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_peppol_evidence_document_hash + BEFORE INSERT ON public.peppol_delivery_evidence + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_evidence_document_hash(); + +REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_payload_hash() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.enforce_peppol_evidence_document_hash() + FROM PUBLIC, anon, authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813165814_peppol_delivery_retention_basis.sql b/supabase/migrations/20260813165814_peppol_delivery_retention_basis.sql new file mode 100644 index 00000000..2d2147bb --- /dev/null +++ b/supabase/migrations/20260813165814_peppol_delivery_retention_basis.sql @@ -0,0 +1,46 @@ +-- A Peppol archive record must inherit the legally corrected BFL retention +-- date from the fiscal period containing the invoice. Refuse staging when the +-- period is missing instead of guessing from the invoice date. + +CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_retention_basis() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = pg_catalog, public +AS $$ +DECLARE + expected_retention date; +BEGIN + SELECT period.retention_expires_at + INTO expected_retention + FROM public.invoices AS invoice + JOIN public.fiscal_periods AS period + ON period.company_id = invoice.company_id + AND invoice.invoice_date BETWEEN period.period_start AND period.period_end + WHERE invoice.id = NEW.invoice_id + AND invoice.company_id = NEW.company_id + ORDER BY period.period_end DESC + LIMIT 1; + + IF expected_retention IS NULL THEN + RAISE EXCEPTION 'Peppol delivery requires a fiscal period retention basis' + USING ERRCODE = 'P0002'; + END IF; + + IF NEW.retention_expires_at IS DISTINCT FROM expected_retention THEN + RAISE EXCEPTION 'Peppol retention date must match the invoice fiscal period' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_peppol_delivery_retention_basis + BEFORE INSERT ON public.peppol_deliveries + FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_retention_basis(); + +REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_retention_basis() + FROM PUBLIC, anon, authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813171856_peppol_retention_source_of_truth.sql b/supabase/migrations/20260813171856_peppol_retention_source_of_truth.sql new file mode 100644 index 00000000..c251c8ad --- /dev/null +++ b/supabase/migrations/20260813171856_peppol_retention_source_of_truth.sql @@ -0,0 +1,103 @@ +-- Keep the legally corrected fiscal-period retention date as the single source +-- of truth for staged Peppol records. Missing periods remain a hard failure. + +CREATE OR REPLACE FUNCTION public.stage_peppol_delivery( + p_company_id uuid, + p_invoice_id uuid, + p_recipient_scheme text, + p_recipient_identifier text, + p_customization_id text, + p_profile_id text, + p_filename text, + p_xml_payload text, + p_xml_sha256 text +) +RETURNS public.peppol_deliveries +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + actor_id uuid := auth.uid(); + invoice_date date; + retention_expiry date; + staged public.peppol_deliveries%ROWTYPE; +BEGIN + IF actor_id IS NULL OR NOT EXISTS ( + SELECT 1 + FROM public.company_members AS member + WHERE member.company_id = p_company_id + AND member.user_id = actor_id + AND member.role <> 'viewer' + ) THEN + RAISE EXCEPTION 'not authorized to stage Peppol delivery' + USING ERRCODE = '42501'; + END IF; + + SELECT invoice.invoice_date + INTO invoice_date + FROM public.invoices AS invoice + WHERE invoice.id = p_invoice_id + AND invoice.company_id = p_company_id + AND invoice.invoice_number IS NOT NULL + AND invoice.status <> 'cancelled'; + + IF invoice_date IS NULL THEN + RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging' + USING ERRCODE = 'P0002'; + END IF; + + SELECT period.retention_expires_at + INTO retention_expiry + FROM public.fiscal_periods AS period + WHERE period.company_id = p_company_id + AND invoice_date BETWEEN period.period_start AND period.period_end + ORDER BY period.period_end DESC + LIMIT 1; + + IF retention_expiry IS NULL THEN + RAISE EXCEPTION 'Peppol delivery requires a fiscal period retention basis' + USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.peppol_deliveries ( + company_id, user_id, invoice_id, + recipient_scheme, recipient_identifier, customization_id, profile_id, + filename, xml_payload, xml_sha256, retention_expires_at + ) VALUES ( + p_company_id, actor_id, p_invoice_id, + p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id, + p_filename, p_xml_payload, lower(p_xml_sha256), retention_expiry + ) + ON CONFLICT (company_id, invoice_id, xml_sha256) DO NOTHING + RETURNING * INTO staged; + + IF staged.id IS NULL THEN + SELECT * INTO STRICT staged + FROM public.peppol_deliveries AS delivery + WHERE delivery.company_id = p_company_id + AND delivery.invoice_id = p_invoice_id + AND delivery.xml_sha256 = lower(p_xml_sha256); + RETURN staged; + END IF; + + INSERT INTO public.peppol_delivery_events ( + company_id, delivery_id, source, provider_event_code, normalized_status, + raw_payload, event_sha256, verification_method, occurred_at + ) VALUES ( + p_company_id, staged.id, 'local', 'staged', 'staged', + jsonb_build_object( + 'invoice_id', p_invoice_id, + 'idempotency_key', staged.idempotency_key, + 'xml_sha256', staged.xml_sha256 + ), + staged.xml_sha256, + 'local', + staged.created_at + ); + + RETURN staged; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/peppol-delivery-lifecycle.pg.test.ts b/tests/pg/peppol-delivery-lifecycle.pg.test.ts new file mode 100644 index 00000000..c55df783 --- /dev/null +++ b/tests/pg/peppol-delivery-lifecycle.pg.test.ts @@ -0,0 +1,307 @@ +import { createHash, randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool, runAsServiceRole, withUserContext } from './setup' +import { insertAuthUser, insertCompanyMember, seedCompany } from './fixtures' + +const XML = 'F-2026-42' +const XML_SHA = createHash('sha256').update(XML).digest('hex') + +async function insertInvoice(userId: string, companyId: string): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, invoice_number, invoice_date, due_date, + status, currency, total) + VALUES ($1, $2, $3, 'F-2026-42', '2026-08-13', '2026-09-12', + 'sent', 'SEK', 125)`, + [id, userId, companyId], + ) + return id +} + +const STAGE_SQL = ` + SELECT (public.stage_peppol_delivery( + $1, $2, '0007', '5566778899', + 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0', + 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', + 'peppol-invoice-F-2026-42.xml', $3, $4 + )).*` + +async function seedStagedDelivery(): Promise<{ + companyId: string + userId: string + invoiceId: string + deliveryId: string + idempotencyKey: string +}> { + const seeded = await seedCompany() + const invoiceId = await insertInvoice(seeded.userId, seeded.companyId) + const deliveryId = randomUUID() + const idempotencyKey = randomUUID() + await getPool().query( + `INSERT INTO public.peppol_deliveries ( + id, company_id, user_id, invoice_id, idempotency_key, + recipient_scheme, recipient_identifier, customization_id, profile_id, + filename, xml_payload, xml_sha256, retention_expires_at + ) VALUES ( + $1, $2, $3, $4, $5, '0007', '5566778899', + 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0', + 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', + 'peppol-invoice-F-2026-42.xml', $6, $7, '2034-01-01' + )`, + [ + deliveryId, + seeded.companyId, + seeded.userId, + invoiceId, + idempotencyKey, + XML, + XML_SHA, + ], + ) + return { ...seeded, invoiceId, deliveryId, idempotencyKey } +} + +describe('stage_peppol_delivery', () => { + it('stores one immutable exact-document snapshot and is idempotent for the same XML', async () => { + const seeded = await seedCompany() + const invoiceId = await insertInvoice(seeded.userId, seeded.companyId) + + await withUserContext(seeded.userId, async (client) => { + const first = await client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]) + const second = await client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]) + + expect(second.rows[0].id).toBe(first.rows[0].id) + expect(first.rows[0]).toMatchObject({ + company_id: seeded.companyId, + invoice_id: invoiceId, + recipient_scheme: '0007', + recipient_identifier: '5566778899', + xml_payload: XML, + xml_sha256: XML_SHA, + status: 'staged', + }) + expect(first.rows[0].idempotency_key).toMatch(/^[0-9a-f-]{36}$/) + expect(first.rows[0].retention_expires_at.toISOString().slice(0, 10)).toBe('2034-01-01') + + await expect(client.query( + `SELECT raw_payload FROM public.peppol_delivery_events WHERE delivery_id = $1`, + [first.rows[0].id], + )).rejects.toThrow(/permission denied/) + }) + }) + + it('rejects viewers and cross-company invoices', async () => { + const seeded = await seedCompany() + const other = await seedCompany() + const invoiceId = await insertInvoice(seeded.userId, seeded.companyId) + const viewerId = await insertAuthUser() + await insertCompanyMember({ companyId: seeded.companyId, userId: viewerId, role: 'viewer' }) + + await expect(withUserContext(viewerId, (client) => + client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]), + )).rejects.toThrow(/not authorized/) + + await expect(withUserContext(other.userId, (client) => + client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]), + )).rejects.toThrow(/not authorized/) + }) + + it('rejects a caller-supplied SHA-256 that does not match the XML', async () => { + const seeded = await seedCompany() + const invoiceId = await insertInvoice(seeded.userId, seeded.companyId) + + await expect(withUserContext(seeded.userId, (client) => + client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, 'b'.repeat(64)]), + )).rejects.toThrow(/does not match the staged payload/) + }) + + it('refuses to guess a retention date when the invoice has no fiscal period', async () => { + const seeded = await seedCompany() + await getPool().query('DELETE FROM public.fiscal_periods WHERE id = $1', [ + seeded.fiscalPeriodId, + ]) + const invoiceId = await insertInvoice(seeded.userId, seeded.companyId) + + await expect(withUserContext(seeded.userId, (client) => + client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]), + )).rejects.toThrow(/requires a fiscal period retention basis/) + }) +}) + +describe('Peppol delivery audit lifecycle', () => { + it('keeps events append-only and does not let late events regress a terminal projection', async () => { + const seeded = await seedStagedDelivery() + const eventSql = `SELECT (public.record_peppol_delivery_event( + $1, $2, 'storecove', 'tenant-42', $3, $4, $5, $6, $7, $8, + $9::jsonb, $10, 'hmac-sha256', $11::timestamptz + )).*` + + await runAsServiceRole(async (client) => { + await client.query(eventSql, [ + seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-1', + 'succeeded', 'transport_succeeded', false, 'Delivered to Corner 3', + JSON.stringify({ event: 'succeeded' }), '1'.repeat(64), '2026-08-13T16:01:00Z', + ]) + await client.query(eventSql, [ + seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-2', + 'temporary_error', 'retryable_failure', false, 'Late retry notice', + JSON.stringify({ event: 'temporary_error' }), '2'.repeat(64), '2026-08-13T16:00:00Z', + ]) + await client.query(eventSql, [ + seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-3', + 'accepted', 'business_accepted', true, 'Buyer accepted', + JSON.stringify({ event: 'accepted' }), '3'.repeat(64), '2026-08-13T16:02:00Z', + ]) + await client.query(eventSql, [ + seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-4', + 'failed', 'failed', true, 'Late contradictory event', + JSON.stringify({ event: 'failed' }), '4'.repeat(64), '2026-08-13T16:03:00Z', + ]) + // Provider retry of event-3: same fingerprint and event id is a no-op. + await client.query(eventSql, [ + seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-3', + 'accepted', 'business_accepted', true, 'Buyer accepted', + JSON.stringify({ event: 'accepted' }), '3'.repeat(64), '2026-08-13T16:02:00Z', + ]) + }) + + const delivery = await getPool().query( + `SELECT provider, provider_tenant_id, provider_submission_id, status, + terminal_at, status_detail + FROM public.peppol_deliveries WHERE id = $1`, + [seeded.deliveryId], + ) + expect(delivery.rows[0]).toMatchObject({ + provider: 'storecove', + provider_tenant_id: 'tenant-42', + provider_submission_id: 'submission-guid', + status: 'business_accepted', + status_detail: 'Buyer accepted', + }) + expect(delivery.rows[0].terminal_at).not.toBeNull() + + const events = await getPool().query( + `SELECT provider_event_id FROM public.peppol_delivery_events + WHERE delivery_id = $1 ORDER BY occurred_at`, + [seeded.deliveryId], + ) + expect(events.rows.map((row) => row.provider_event_id)).toEqual([ + 'event-2', 'event-1', 'event-3', 'event-4', + ]) + + await expect(getPool().query( + `UPDATE public.peppol_delivery_events SET detail = 'changed' WHERE delivery_id = $1`, + [seeded.deliveryId], + )).rejects.toThrow(/append-only/) + await expect(getPool().query( + `DELETE FROM public.peppol_deliveries WHERE id = $1`, + [seeded.deliveryId], + )).rejects.toThrow(/cannot be deleted/) + }) + + it('stores provider evidence idempotently and keeps its exact document immutable', async () => { + const seeded = await seedStagedDelivery() + await runAsServiceRole(async (client) => { + await client.query( + `SELECT public.record_peppol_delivery_event( + $1, $2, 'storecove', 'tenant-42', $3, $4, + 'submission_accepted', 'submission_accepted', false, NULL, + '{"event":"submission_accepted"}'::jsonb, $5, 'hmac-sha256', now() + )`, + [ + seeded.companyId, + seeded.idempotencyKey, + randomUUID(), + randomUUID(), + '5'.repeat(64), + ], + ) + const evidenceSql = `SELECT public.record_peppol_delivery_evidence( + $1, $2, 'storecove', 'access_point_evidence', '{"receipt":"ok"}'::jsonb, + $3, $4, $5, '2026-08-13T16:05:00Z' + ) AS id` + const first = await client.query(evidenceSql, [ + seeded.companyId, seeded.idempotencyKey, XML, XML_SHA, '6'.repeat(64), + ]) + const second = await client.query(evidenceSql, [ + seeded.companyId, seeded.idempotencyKey, XML, XML_SHA, '6'.repeat(64), + ]) + expect(second.rows[0].id).toBe(first.rows[0].id) + }) + + const evidence = await getPool().query( + `SELECT document_payload, document_sha256 + FROM public.peppol_delivery_evidence WHERE delivery_id = $1`, + [seeded.deliveryId], + ) + expect(evidence.rows).toEqual([{ document_payload: XML, document_sha256: XML_SHA }]) + + await expect(getPool().query( + `UPDATE public.peppol_delivery_evidence SET document_payload = 'changed' + WHERE delivery_id = $1`, + [seeded.deliveryId], + )).rejects.toThrow(/append-only/) + }) + + it('rejects provider evidence whose exact-document hash is inconsistent', async () => { + const seeded = await seedStagedDelivery() + await runAsServiceRole(async (client) => { + await client.query( + `SELECT public.record_peppol_delivery_event( + $1, $2, 'storecove', 'tenant-42', $3, $4, + 'submission_accepted', 'submission_accepted', false, NULL, + '{"event":"submission_accepted"}'::jsonb, $5, 'hmac-sha256', now() + )`, + [ + seeded.companyId, + seeded.idempotencyKey, + randomUUID(), + randomUUID(), + '7'.repeat(64), + ], + ) + + await expect(client.query( + `SELECT public.record_peppol_delivery_evidence( + $1, $2, 'storecove', 'access_point_evidence', '{}'::jsonb, + $3, $4, $5, now() + )`, + [seeded.companyId, seeded.idempotencyKey, XML, 'b'.repeat(64), '8'.repeat(64)], + )).rejects.toThrow(/does not match the payload/) + }) + }) +}) + +describe('Peppol delivery RPC privileges', () => { + it('keeps raw tables closed and provider writes service-role only', async () => { + const privileges = await getPool().query<{ + authenticated_table_select: boolean + anon_table_select: boolean + authenticated_event_exec: boolean + service_event_exec: boolean + }>(` + SELECT + has_table_privilege('authenticated', 'public.peppol_deliveries', 'SELECT') + AS authenticated_table_select, + has_table_privilege('anon', 'public.peppol_deliveries', 'SELECT') + AS anon_table_select, + has_function_privilege( + 'authenticated', + 'public.record_peppol_delivery_event(uuid,uuid,text,text,text,text,text,text,boolean,text,jsonb,text,text,timestamptz)', + 'EXECUTE' + ) AS authenticated_event_exec, + has_function_privilege( + 'service_role', + 'public.record_peppol_delivery_event(uuid,uuid,text,text,text,text,text,text,boolean,text,jsonb,text,text,timestamptz)', + 'EXECUTE' + ) AS service_event_exec + `) + expect(privileges.rows[0]).toEqual({ + authenticated_table_select: false, + anon_table_select: false, + authenticated_event_exec: false, + service_event_exec: true, + }) + }) +})