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:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user