* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery Second Peppol slice (#546). Qvalia confirmed that sending needs no per-company account, so receiving keeps the consolidated partner account: each company publishes its 0007:orgnr on our account and inbound documents are routed by the AccountingCustomerParty endpoint. - PeppolTransport grows optional receiving methods (registerRecipient, unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices / readcreditnotes, exact XML fetch). - lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON (xml2js-style prefixed keys, verified against Qvalia's real inbound test invoice, kept as a fixture) into a neutral document: parties, payment means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines, embedded attachments, credit notes. - Migration 20260821170000: peppol_registrations (one live row per company and participant), peppol_inbound_documents (exact XML immutable and undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability and routing. - POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in Settings > Fakturering; personnummer-based companies are refused until 0088 GLN exists; sandbox refused. - GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver. lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document (upload_source e_invoice, extractionOwner none), an embedded PDF when present, and creates the inbox row with the extraction filled from the UBL (confidence 1, no model pass), matching the supplier by org number. The existing inbox review/convert flow takes over. - document-service accepts application/xml for the archive; inbox list shows a Peppol icon. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES; the pg fixture for a deregistered row now carries deregistered_at as the status-shape constraint requires; the archive insert is an inline literal and the one generic processing-state updater is accounted for in the ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
|
* Inbound Peppol documents: pull what the Access Point holds for us, archive
|
|
* the exact XML, route each document to the company whose identifier it was
|
|
* addressed to, and hand it to the supplier-invoice inbox.
|
|
*
|
|
* Every step is recorded on `peppol_inbound_documents`, so a crash between
|
|
* "archived" and "in the inbox" shows up as a row in `routed`/`failed` state
|
|
* that the next run picks up again, instead of a silently lost e-invoice.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Logger } from '@/lib/logger'
|
|
import { ISO_DATE_RE } from '@/lib/invariants'
|
|
import { roundOre } from '@/lib/money'
|
|
import { sha256Hex } from '@/lib/invoices/peppol-delivery'
|
|
import {
|
|
parseUblJsonDocument,
|
|
type PeppolInboundDocument,
|
|
} from '@/lib/invoices/peppol-inbound-ubl'
|
|
import type {
|
|
PeppolInboundDocumentType,
|
|
PeppolInboundMessage,
|
|
PeppolTransport,
|
|
} from '@/lib/invoices/peppol-transport'
|
|
|
|
export type PeppolInboundStatus = 'received' | 'routed' | 'unrouted' | 'converted' | 'ignored' | 'failed'
|
|
|
|
export interface PeppolInboundRow {
|
|
id: string
|
|
provider: string
|
|
provider_document_id: string
|
|
document_type: PeppolInboundDocumentType
|
|
document_id: string | null
|
|
issue_date: string | null
|
|
due_date: string | null
|
|
currency: string | null
|
|
payable_amount: number | null
|
|
sender_scheme: string | null
|
|
sender_identifier: string | null
|
|
sender_name: string | null
|
|
recipient_scheme: string | null
|
|
recipient_identifier: string | null
|
|
company_id: string | null
|
|
status: PeppolInboundStatus
|
|
inbox_item_id: string | null
|
|
supplier_invoice_id: string | null
|
|
xml_document_id: string | null
|
|
xml_payload: string | null
|
|
xml_sha256: string | null
|
|
ubl_json: Record<string, unknown>
|
|
summary: Record<string, unknown>
|
|
received_at: string
|
|
processed_at: string | null
|
|
last_error: string | null
|
|
}
|
|
|
|
/** What the inbox integration receives for one routed document. */
|
|
export interface PeppolInboundDelivery {
|
|
row: PeppolInboundRow
|
|
companyId: string
|
|
document: PeppolInboundDocument
|
|
xml: string | null
|
|
}
|
|
|
|
export type PeppolInboundDeliverer = (delivery: PeppolInboundDelivery) => Promise<{
|
|
inboxItemId: string | null
|
|
supplierInvoiceId?: string | null
|
|
/** document_attachments id of the archived exact XML, when the deliverer archived it. */
|
|
xmlDocumentId?: string | null
|
|
}>
|
|
|
|
export interface PeppolInboundSyncResult {
|
|
listed: number
|
|
archived: number
|
|
duplicates: number
|
|
routed: number
|
|
unrouted: number
|
|
delivered: number
|
|
failed: number
|
|
errors: Array<{ providerDocumentId: string; reason: string }>
|
|
}
|
|
|
|
function cleanIsoDate(value: string | null): string | null {
|
|
return value && ISO_DATE_RE.test(value) ? value : null
|
|
}
|
|
|
|
function roundMoney(value: number | null): number | null {
|
|
return value === null ? null : roundOre(value)
|
|
}
|
|
|
|
function describeError(err: unknown): string {
|
|
return (err instanceof Error ? err.message : String(err)).slice(0, 500)
|
|
}
|
|
|
|
/** Company for a recipient identifier, via a live registration; null when nobody is registered. */
|
|
export async function resolvePeppolRecipientCompany(args: {
|
|
service: SupabaseClient
|
|
provider: string
|
|
scheme: string
|
|
identifier: string
|
|
}): Promise<string | null> {
|
|
const { data, error } = await args.service
|
|
.from('peppol_registrations')
|
|
.select('company_id')
|
|
.eq('provider', args.provider)
|
|
.eq('participant_scheme', args.scheme)
|
|
.eq('participant_identifier', args.identifier.replace(/\s/g, ''))
|
|
.eq('status', 'registered')
|
|
.limit(1)
|
|
.maybeSingle()
|
|
if (error) throw new Error(`Failed to resolve Peppol recipient: ${error.message}`)
|
|
return (data as { company_id: string } | null)?.company_id ?? null
|
|
}
|
|
|
|
/**
|
|
* Archive one message from the provider. Idempotent on (provider, provider
|
|
* document id): a message seen before returns the stored row and
|
|
* `created: false`.
|
|
*/
|
|
export async function archiveInboundPeppolMessage(args: {
|
|
service: SupabaseClient
|
|
transport: PeppolTransport
|
|
message: PeppolInboundMessage
|
|
log: Logger
|
|
}): Promise<{ row: PeppolInboundRow; document: PeppolInboundDocument | null; created: boolean }> {
|
|
const { service, transport, message } = args
|
|
|
|
const { data: existing, error: existingError } = await service
|
|
.from('peppol_inbound_documents')
|
|
.select('*')
|
|
.eq('provider', message.provider)
|
|
.eq('provider_document_id', message.providerDocumentId)
|
|
.maybeSingle()
|
|
if (existingError) throw new Error(`Failed to read inbound Peppol archive: ${existingError.message}`)
|
|
if (existing) {
|
|
const row = existing as PeppolInboundRow
|
|
return { row, document: parseUblJsonDocument(row.ubl_json), created: false }
|
|
}
|
|
|
|
const document = parseUblJsonDocument(message.payload)
|
|
let xml: string | null = null
|
|
try {
|
|
xml = transport.fetchInboundDocumentXml
|
|
? await transport.fetchInboundDocumentXml(message.providerDocumentId, message.documentType)
|
|
: null
|
|
} catch (err) {
|
|
// The JSON payload is already in hand; the exact XML is retried on a later
|
|
// pass rather than blocking the archive of what we have.
|
|
args.log.warn('inbound Peppol XML fetch failed, archiving JSON only', {
|
|
providerDocumentId: message.providerDocumentId,
|
|
reason: describeError(err),
|
|
})
|
|
}
|
|
|
|
const recipient = document?.customer.endpoint ?? null
|
|
const { data, error } = await service
|
|
.from('peppol_inbound_documents')
|
|
.insert({
|
|
provider: message.provider,
|
|
provider_document_id: message.providerDocumentId,
|
|
document_type: message.documentType,
|
|
document_id: document?.documentId || null,
|
|
issue_date: cleanIsoDate(document?.issueDate ?? null),
|
|
due_date: cleanIsoDate(document?.dueDate ?? null),
|
|
currency: document?.currency && /^[A-Z]{3}$/.test(document.currency) ? document.currency : null,
|
|
payable_amount: roundMoney(document?.totals.payable ?? null),
|
|
sender_scheme: document?.supplier.endpoint?.scheme ?? null,
|
|
sender_identifier: document?.supplier.endpoint?.identifier ?? null,
|
|
sender_name: document?.supplier.name ?? null,
|
|
recipient_scheme: recipient?.scheme ?? null,
|
|
recipient_identifier: recipient?.identifier ?? null,
|
|
status: 'received',
|
|
xml_payload: xml,
|
|
xml_sha256: xml ? sha256Hex(xml) : null,
|
|
ubl_json: message.payload,
|
|
summary: document ? { warnings: document.warnings, lines: document.lines.length, attachments: document.attachments.length } : { unparsed: true },
|
|
received_at: message.receivedAt ?? new Date().toISOString(),
|
|
})
|
|
.select('*')
|
|
.single()
|
|
if (error || !data) {
|
|
// A concurrent run may have archived it first: re-read instead of failing.
|
|
if (error && /duplicate|unique/i.test(error.message)) {
|
|
const { data: raced } = await service
|
|
.from('peppol_inbound_documents')
|
|
.select('*')
|
|
.eq('provider', message.provider)
|
|
.eq('provider_document_id', message.providerDocumentId)
|
|
.maybeSingle()
|
|
if (raced) {
|
|
const row = raced as PeppolInboundRow
|
|
return { row, document: parseUblJsonDocument(row.ubl_json), created: false }
|
|
}
|
|
}
|
|
throw new Error(`Failed to archive inbound Peppol document: ${error?.message ?? 'no row'}`)
|
|
}
|
|
return { row: data as PeppolInboundRow, document, created: true }
|
|
}
|
|
|
|
async function updateRow(
|
|
service: SupabaseClient,
|
|
id: string,
|
|
patch: Partial<PeppolInboundRow>,
|
|
): Promise<PeppolInboundRow> {
|
|
const { data, error } = await service
|
|
.from('peppol_inbound_documents')
|
|
.update(patch)
|
|
.eq('id', id)
|
|
.select('*')
|
|
.single()
|
|
if (error || !data) throw new Error(`Failed to update inbound Peppol document: ${error?.message ?? 'no row'}`)
|
|
return data as PeppolInboundRow
|
|
}
|
|
|
|
/**
|
|
* Route an archived document to its company and deliver it to the inbox.
|
|
* Safe to call again on rows left in `received`/`routed`/`failed`.
|
|
*/
|
|
export async function processInboundPeppolRow(args: {
|
|
service: SupabaseClient
|
|
row: PeppolInboundRow
|
|
document: PeppolInboundDocument | null
|
|
deliver: PeppolInboundDeliverer | null
|
|
log: Logger
|
|
}): Promise<{ row: PeppolInboundRow; outcome: 'delivered' | 'routed' | 'unrouted' | 'failed' | 'skipped' }> {
|
|
const { service, log } = args
|
|
let row = args.row
|
|
if (row.status === 'converted' || row.status === 'ignored') return { row, outcome: 'skipped' }
|
|
|
|
if (!row.company_id) {
|
|
if (!row.recipient_scheme || !row.recipient_identifier) {
|
|
row = await updateRow(service, row.id, {
|
|
status: 'unrouted',
|
|
last_error: 'recipient endpoint missing in document',
|
|
processed_at: new Date().toISOString(),
|
|
})
|
|
return { row, outcome: 'unrouted' }
|
|
}
|
|
const companyId = await resolvePeppolRecipientCompany({
|
|
service,
|
|
provider: row.provider,
|
|
scheme: row.recipient_scheme,
|
|
identifier: row.recipient_identifier,
|
|
})
|
|
if (!companyId) {
|
|
row = await updateRow(service, row.id, {
|
|
status: 'unrouted',
|
|
last_error: null,
|
|
processed_at: new Date().toISOString(),
|
|
})
|
|
log.warn('inbound Peppol document for an unregistered recipient', {
|
|
providerDocumentId: row.provider_document_id,
|
|
recipient: `${row.recipient_scheme}:${row.recipient_identifier}`,
|
|
})
|
|
return { row, outcome: 'unrouted' }
|
|
}
|
|
row = await updateRow(service, row.id, { company_id: companyId, status: 'routed', last_error: null })
|
|
}
|
|
|
|
if (!args.deliver || !args.document) {
|
|
if (!args.document) {
|
|
row = await updateRow(service, row.id, {
|
|
status: 'failed',
|
|
last_error: 'document could not be read as UBL',
|
|
processed_at: new Date().toISOString(),
|
|
})
|
|
return { row, outcome: 'failed' }
|
|
}
|
|
return { row, outcome: 'routed' }
|
|
}
|
|
|
|
try {
|
|
const result = await args.deliver({
|
|
row,
|
|
companyId: row.company_id as string,
|
|
document: args.document,
|
|
xml: row.xml_payload,
|
|
})
|
|
row = await updateRow(service, row.id, {
|
|
status: 'converted',
|
|
inbox_item_id: result.inboxItemId,
|
|
supplier_invoice_id: result.supplierInvoiceId ?? null,
|
|
xml_document_id: result.xmlDocumentId ?? row.xml_document_id ?? null,
|
|
processed_at: new Date().toISOString(),
|
|
last_error: null,
|
|
})
|
|
return { row, outcome: 'delivered' }
|
|
} catch (err) {
|
|
const reason = describeError(err)
|
|
log.error('inbound Peppol delivery to inbox failed', err as Error, { providerDocumentId: row.provider_document_id })
|
|
row = await updateRow(service, row.id, { status: 'failed', last_error: reason })
|
|
return { row, outcome: 'failed' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One polling pass: list unread invoices and credit notes at the provider,
|
|
* archive, route and deliver each. Errors are per document; the pass always
|
|
* finishes.
|
|
*/
|
|
export async function syncInboundPeppolDocuments(args: {
|
|
service: SupabaseClient
|
|
transport: PeppolTransport
|
|
deliver: PeppolInboundDeliverer | null
|
|
log: Logger
|
|
limit?: number
|
|
}): Promise<PeppolInboundSyncResult> {
|
|
const { service, transport, log } = args
|
|
const result: PeppolInboundSyncResult = {
|
|
listed: 0, archived: 0, duplicates: 0, routed: 0, unrouted: 0, delivered: 0, failed: 0, errors: [],
|
|
}
|
|
if (!transport.listInboundDocuments) return result
|
|
|
|
for (const documentType of ['Invoice', 'CreditNote'] as const) {
|
|
let messages: PeppolInboundMessage[] = []
|
|
try {
|
|
messages = await transport.listInboundDocuments({ documentType, limit: args.limit ?? 50 })
|
|
} catch (err) {
|
|
log.error('inbound Peppol listing failed', err as Error, { documentType })
|
|
result.errors.push({ providerDocumentId: `list:${documentType}`, reason: describeError(err) })
|
|
continue
|
|
}
|
|
result.listed += messages.length
|
|
|
|
for (const message of messages) {
|
|
try {
|
|
const archived = await archiveInboundPeppolMessage({ service, transport, message, log })
|
|
if (archived.created) result.archived += 1
|
|
else result.duplicates += 1
|
|
const processed = await processInboundPeppolRow({
|
|
service,
|
|
row: archived.row,
|
|
document: archived.document,
|
|
deliver: args.deliver,
|
|
log,
|
|
})
|
|
if (processed.outcome === 'delivered') result.delivered += 1
|
|
else if (processed.outcome === 'routed') result.routed += 1
|
|
else if (processed.outcome === 'unrouted') result.unrouted += 1
|
|
else if (processed.outcome === 'failed') result.failed += 1
|
|
} catch (err) {
|
|
result.failed += 1
|
|
result.errors.push({ providerDocumentId: message.providerDocumentId, reason: describeError(err) })
|
|
log.error('inbound Peppol document failed', err as Error, { providerDocumentId: message.providerDocumentId })
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|