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
This commit is contained in:
Mattsson
2026-08-13 19:44:32 +02:00
committed by GitHub
parent 05380ddf54
commit 07e89d9b52
25 changed files with 2376 additions and 84 deletions
+1
View File
@@ -935,6 +935,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+69 -12
View File
@@ -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 && (
<Button
variant="outline"
onClick={downloadPeppolXml}
disabled={isDownloadingPeppol}
>
{isDownloadingPeppol ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('download_peppol_xml')}
</Button>
<>
<Button
variant="outline"
onClick={downloadPeppolXml}
disabled={isDownloadingPeppol}
>
{isDownloadingPeppol ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('download_peppol_xml')}
</Button>
<Button
variant="outline"
onClick={preparePeppolDelivery}
disabled={isPreparingPeppol || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isPreparingPeppol ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileCheck2 className="mr-2 h-4 w-4" />
)}
{t('prepare_peppol_delivery')}
</Button>
<span className="inline-flex" title={t('peppol_provider_required')}>
<Button variant="outline" disabled>
<Send className="mr-2 h-4 w-4" />
{t('send_via_peppol')}
</Button>
<span className="sr-only">{t('peppol_provider_required')}</span>
</span>
</>
)}
</div>
</div>
@@ -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('<cbc:PayableAmount currencyID="SEK">125.00</cbc:PayableAmount>')
})
})
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('<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>'),
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 })
})
})
@@ -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',
})
})
})
@@ -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 }))
}
},
)
+147 -65
View File
@@ -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<typeof generatePeppolBisBillingInvoice>,
{ 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<typeof errorResponseFromCode>[1]
requestId: string
}): Promise<LoadPeppolDocumentResult> {
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 },
)
+42 -5
View File
@@ -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/)
+6
View File
@@ -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
}
@@ -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)
+1
View File
@@ -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':
@@ -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')
}
})
})
@@ -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('<Invoice>åäö</Invoice>')).toBe(
'ab1c7c9e3a2780e73140e40a0af1a1d355026e3b754049bc6b0e8d03490b4d65',
)
})
})
@@ -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')
})
})
+9 -1
View File
@@ -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 },
}
}
+128
View File
@@ -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<PeppolInvoiceResult, { ok: true }>
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<StagedPeppolDelivery> {
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<PeppolDeliverySummary[]> {
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<PeppolDeliverySummary> {
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<string> {
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
}
+141
View File
@@ -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<string, unknown>
eventSha256: string
verificationMethod: string
}
export interface PeppolDeliveryEvidence {
provider: string
evidenceType: string
payload: Record<string, unknown>
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<PeppolRecipientLookup>
submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt>
verifyWebhook(request: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]>
retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]>
}
const transports = new Map<string, PeppolTransport>()
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 }
}
+9
View File
@@ -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' },
+7
View File
@@ -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",
+7
View File
@@ -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",
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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 = '<Invoice><cbc:ID>F-2026-42</cbc:ID></Invoice>'
const XML_SHA = createHash('sha256').update(XML).digest('hex')
async function insertInvoice(userId: string, companyId: string): Promise<string> {
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,
})
})
})