Files
accounted/tests/pg/peppol-delivery-lifecycle.pg.test.ts
T
Mattsson 07e89d9b52 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
2026-08-13 19:44:32 +02:00

308 lines
12 KiB
TypeScript

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,
})
})
})