* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
347 lines
12 KiB
TypeScript
347 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 { describeError, 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)
|
|
}
|
|
|
|
/** 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
|
|
}
|