* 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>
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
/**
|
|
* Inbound Peppol BIS Billing 3 documents, read from the UBL-JSON the Access
|
|
* Point hands us. Qvalia's JSON is the xml2js rendering of the UBL XML
|
|
* (verified live 2026-08-21): element keys keep their namespace prefix
|
|
* (`cac:AccountingSupplierParty`, `cbc:ID`), every element is an array, text
|
|
* sits under `_` and attributes under `$`. The OASIS UBL-JSON form (no
|
|
* prefixes, attributes beside `_`) is read the same way so a later XML->JSON
|
|
* converter does not need a second reader.
|
|
*
|
|
* This is a reader, not a validator: the Access Point validated the document
|
|
* against the Peppol rules before accepting it. Anything the reader cannot
|
|
* make sense of lands in `warnings` instead of throwing, because a received
|
|
* e-invoice is räkenskapsinformation and must reach the inbox even when a
|
|
* field is odd.
|
|
*/
|
|
|
|
export type UblJsonNode = Record<string, unknown>
|
|
|
|
export interface PeppolInboundEndpoint {
|
|
scheme: string
|
|
identifier: string
|
|
}
|
|
|
|
export interface PeppolInboundParty {
|
|
name: string | null
|
|
endpoint: PeppolInboundEndpoint | null
|
|
/** cac:PartyLegalEntity/cbc:CompanyID as written, e.g. "556732-1707". */
|
|
legalCompanyId: string | null
|
|
/** Ten digits when a Swedish organisation number could be derived, else null. */
|
|
orgNumber: string | null
|
|
vatNumber: string | null
|
|
street: string | null
|
|
additionalStreet: string | null
|
|
city: string | null
|
|
postalZone: string | null
|
|
countryCode: string | null
|
|
email: string | null
|
|
phone: string | null
|
|
}
|
|
|
|
export interface PeppolInboundPaymentMeans {
|
|
code: string | null
|
|
/** cbc:PaymentID, for Swedish payments the OCR reference. */
|
|
paymentId: string | null
|
|
accountId: string | null
|
|
accountName: string | null
|
|
/** cac:FinancialInstitutionBranch/cbc:ID, e.g. SE:BANKGIRO, SE:PLUSGIRO, BBAN, or a BIC. */
|
|
branchId: string | null
|
|
bankgiro: string | null
|
|
plusgiro: string | null
|
|
iban: string | null
|
|
}
|
|
|
|
export interface PeppolInboundTaxSubtotal {
|
|
taxableAmount: number | null
|
|
taxAmount: number | null
|
|
categoryId: string | null
|
|
percent: number | null
|
|
exemptionReason: string | null
|
|
}
|
|
|
|
export interface PeppolInboundLine {
|
|
id: string | null
|
|
name: string | null
|
|
description: string | null
|
|
quantity: number | null
|
|
unitCode: string | null
|
|
priceAmount: number | null
|
|
lineExtensionAmount: number | null
|
|
vatCategoryId: string | null
|
|
vatPercent: number | null
|
|
sellersItemId: string | null
|
|
buyerAccountingCost: string | null
|
|
}
|
|
|
|
export interface PeppolInboundAttachment {
|
|
id: string | null
|
|
description: string | null
|
|
filename: string | null
|
|
mimeType: string | null
|
|
/** Base64 as embedded in the document; decoded by the caller. */
|
|
base64: string | null
|
|
externalUri: string | null
|
|
}
|
|
|
|
export interface PeppolInboundTotals {
|
|
lineExtension: number | null
|
|
taxExclusive: number | null
|
|
taxInclusive: number | null
|
|
allowanceTotal: number | null
|
|
chargeTotal: number | null
|
|
prepaid: number | null
|
|
payableRounding: number | null
|
|
payable: number | null
|
|
taxAmount: number | null
|
|
}
|
|
|
|
export interface PeppolInboundDocument {
|
|
documentType: 'Invoice' | 'CreditNote'
|
|
customizationId: string | null
|
|
profileId: string | null
|
|
documentId: string
|
|
issueDate: string | null
|
|
dueDate: string | null
|
|
typeCode: string | null
|
|
currency: string | null
|
|
buyerReference: string | null
|
|
orderReference: string | null
|
|
/** For credit notes: the invoice(s) being credited (cac:BillingReference). */
|
|
billingReferences: string[]
|
|
note: string | null
|
|
paymentTermsNote: string | null
|
|
supplier: PeppolInboundParty
|
|
customer: PeppolInboundParty
|
|
paymentMeans: PeppolInboundPaymentMeans[]
|
|
totals: PeppolInboundTotals
|
|
taxSubtotals: PeppolInboundTaxSubtotal[]
|
|
lines: PeppolInboundLine[]
|
|
attachments: PeppolInboundAttachment[]
|
|
warnings: string[]
|
|
}
|
|
|
|
function asNode(value: unknown): UblJsonNode | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as UblJsonNode)
|
|
: null
|
|
}
|
|
|
|
/** All child elements named `name`, accepting both prefixed and bare keys. */
|
|
export function ublChildren(node: UblJsonNode | null, name: string): UblJsonNode[] {
|
|
if (!node) return []
|
|
const raw = node[name] ?? node[`cac:${name}`] ?? node[`cbc:${name}`] ?? node[`ext:${name}`]
|
|
if (raw === undefined || raw === null) return []
|
|
const list = Array.isArray(raw) ? raw : [raw]
|
|
return list.map((item) => {
|
|
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
|
|
return { _: String(item) }
|
|
}
|
|
return asNode(item)
|
|
}).filter((item): item is UblJsonNode => item !== null)
|
|
}
|
|
|
|
export function ublFirst(node: UblJsonNode | null, name: string): UblJsonNode | null {
|
|
return ublChildren(node, name)[0] ?? null
|
|
}
|
|
|
|
/** Text content of an element node (`_`), trimmed, empty -> null. */
|
|
export function ublNodeText(node: UblJsonNode | null): string | null {
|
|
if (!node) return null
|
|
const raw = node._
|
|
if (typeof raw === 'string') {
|
|
const trimmed = raw.trim()
|
|
return trimmed ? trimmed : null
|
|
}
|
|
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw)
|
|
return null
|
|
}
|
|
|
|
export function ublText(node: UblJsonNode | null, name: string): string | null {
|
|
return ublNodeText(ublFirst(node, name))
|
|
}
|
|
|
|
/** Attribute of an element node: xml2js keeps them under `$`, OASIS beside `_`. */
|
|
export function ublAttr(node: UblJsonNode | null, attribute: string): string | null {
|
|
if (!node) return null
|
|
const direct = node[attribute]
|
|
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
|
const bag = asNode(node.$)
|
|
const nested = bag?.[attribute]
|
|
return typeof nested === 'string' && nested.trim() ? nested.trim() : null
|
|
}
|
|
|
|
export function ublNumber(node: UblJsonNode | null, name: string): number | null {
|
|
const text = ublText(node, name)
|
|
if (text === null) return null
|
|
const normalized = text.replace(/\s/g, '').replace(',', '.')
|
|
const value = Number(normalized)
|
|
return Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
function digitsOnly(value: string | null): string {
|
|
return (value ?? '').replace(/\D/g, '')
|
|
}
|
|
|
|
/** Ten-digit Swedish organisation number from an endpoint or legal id, else null. */
|
|
export function swedishOrgNumberFrom(...candidates: Array<string | null | undefined>): string | null {
|
|
for (const candidate of candidates) {
|
|
const digits = digitsOnly(candidate ?? null)
|
|
if (digits.length === 10) return digits
|
|
// Twelve-digit form (16 + orgnr) used by some senders.
|
|
if (digits.length === 12 && digits.startsWith('16')) return digits.slice(2)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readEndpoint(party: UblJsonNode | null): PeppolInboundEndpoint | null {
|
|
const endpoint = ublFirst(party, 'EndpointID')
|
|
const identifier = ublNodeText(endpoint)
|
|
const scheme = ublAttr(endpoint, 'schemeID')
|
|
if (!identifier || !scheme) return null
|
|
return { scheme, identifier: identifier.replace(/\s/g, '') }
|
|
}
|
|
|
|
function readParty(root: UblJsonNode | null, container: string, warnings: string[]): PeppolInboundParty {
|
|
const party = ublFirst(ublFirst(root, container), 'Party')
|
|
if (!party) warnings.push(`${container} missing`)
|
|
const address = ublFirst(party, 'PostalAddress')
|
|
const legal = ublFirst(party, 'PartyLegalEntity')
|
|
const contact = ublFirst(party, 'Contact')
|
|
const endpoint = readEndpoint(party)
|
|
const legalCompanyId = ublText(legal, 'CompanyID')
|
|
const vatNumber = ublChildren(party, 'PartyTaxScheme')
|
|
.map((scheme) => ({ id: ublText(scheme, 'CompanyID'), kind: ublText(ublFirst(scheme, 'TaxScheme'), 'ID') }))
|
|
.find((scheme) => scheme.kind === 'VAT' && scheme.id)?.id ?? null
|
|
const partyIdentification = ublText(ublFirst(party, 'PartyIdentification'), 'ID')
|
|
const countryCode = ublText(ublFirst(address, 'Country'), 'IdentificationCode')
|
|
const swedish = !countryCode || countryCode.toUpperCase() === 'SE'
|
|
|| endpoint?.scheme === '0007' || (vatNumber ?? '').toUpperCase().startsWith('SE')
|
|
|
|
return {
|
|
name: ublText(legal, 'RegistrationName') ?? ublText(ublFirst(party, 'PartyName'), 'Name'),
|
|
endpoint,
|
|
legalCompanyId,
|
|
orgNumber: swedish
|
|
? swedishOrgNumberFrom(
|
|
endpoint?.scheme === '0007' ? endpoint.identifier : null,
|
|
legalCompanyId,
|
|
partyIdentification,
|
|
)
|
|
: null,
|
|
vatNumber,
|
|
street: ublText(address, 'StreetName'),
|
|
additionalStreet: ublText(address, 'AdditionalStreetName'),
|
|
city: ublText(address, 'CityName'),
|
|
postalZone: ublText(address, 'PostalZone'),
|
|
countryCode,
|
|
email: ublText(contact, 'ElectronicMail'),
|
|
phone: ublText(contact, 'Telephone'),
|
|
}
|
|
}
|
|
|
|
function readPaymentMeans(root: UblJsonNode | null): PeppolInboundPaymentMeans[] {
|
|
return ublChildren(root, 'PaymentMeans').map((means) => {
|
|
const account = ublFirst(means, 'PayeeFinancialAccount')
|
|
const accountId = ublText(account, 'ID')
|
|
const branchId = ublText(ublFirst(account, 'FinancialInstitutionBranch'), 'ID')
|
|
const branch = (branchId ?? '').toUpperCase()
|
|
const digits = digitsOnly(accountId)
|
|
const isIban = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test((accountId ?? '').replace(/\s/g, '').toUpperCase())
|
|
return {
|
|
code: ublText(means, 'PaymentMeansCode'),
|
|
paymentId: ublText(means, 'PaymentID'),
|
|
accountId,
|
|
accountName: ublText(account, 'Name'),
|
|
branchId,
|
|
bankgiro: branch === 'SE:BANKGIRO' && digits ? digits : null,
|
|
plusgiro: branch === 'SE:PLUSGIRO' && digits ? digits : null,
|
|
iban: isIban ? (accountId ?? '').replace(/\s/g, '').toUpperCase() : null,
|
|
}
|
|
})
|
|
}
|
|
|
|
function readLines(root: UblJsonNode | null, lineElement: 'InvoiceLine' | 'CreditNoteLine'): PeppolInboundLine[] {
|
|
const quantityElement = lineElement === 'InvoiceLine' ? 'InvoicedQuantity' : 'CreditedQuantity'
|
|
return ublChildren(root, lineElement).map((line) => {
|
|
const item = ublFirst(line, 'Item')
|
|
const tax = ublFirst(item, 'ClassifiedTaxCategory')
|
|
const quantity = ublFirst(line, quantityElement)
|
|
return {
|
|
id: ublText(line, 'ID'),
|
|
name: ublText(item, 'Name'),
|
|
description: ublText(item, 'Description') ?? ublText(line, 'Note'),
|
|
quantity: ublNumber(line, quantityElement),
|
|
unitCode: ublAttr(quantity, 'unitCode'),
|
|
priceAmount: ublNumber(ublFirst(line, 'Price'), 'PriceAmount'),
|
|
lineExtensionAmount: ublNumber(line, 'LineExtensionAmount'),
|
|
vatCategoryId: ublText(tax, 'ID'),
|
|
vatPercent: ublNumber(tax, 'Percent'),
|
|
sellersItemId: ublText(ublFirst(item, 'SellersItemIdentification'), 'ID'),
|
|
buyerAccountingCost: ublText(line, 'AccountingCost'),
|
|
}
|
|
})
|
|
}
|
|
|
|
function readAttachments(root: UblJsonNode | null): PeppolInboundAttachment[] {
|
|
return ublChildren(root, 'AdditionalDocumentReference').map((reference) => {
|
|
const attachment = ublFirst(reference, 'Attachment')
|
|
const embedded = ublFirst(attachment, 'EmbeddedDocumentBinaryObject')
|
|
return {
|
|
id: ublText(reference, 'ID'),
|
|
description: ublText(reference, 'DocumentDescription'),
|
|
filename: ublAttr(embedded, 'filename'),
|
|
mimeType: ublAttr(embedded, 'mimeCode'),
|
|
base64: ublNodeText(embedded)?.replace(/\s/g, '') ?? null,
|
|
externalUri: ublText(ublFirst(attachment, 'ExternalReference'), 'URI'),
|
|
}
|
|
}).filter((attachment) => attachment.base64 || attachment.externalUri)
|
|
}
|
|
|
|
/**
|
|
* Read an inbound UBL-JSON message. Accepts the message envelope Qvalia
|
|
* returns (`{ Invoice: {...}, integrationId }`), a bare root, or a CreditNote.
|
|
* Returns null when no UBL root element can be found.
|
|
*/
|
|
export function parseUblJsonDocument(message: unknown): PeppolInboundDocument | null {
|
|
const envelope = asNode(message)
|
|
if (!envelope) return null
|
|
|
|
let documentType: 'Invoice' | 'CreditNote' | null = null
|
|
let root: UblJsonNode | null = null
|
|
for (const candidate of ['Invoice', 'CreditNote'] as const) {
|
|
const found = ublFirst(envelope, candidate)
|
|
if (found) {
|
|
documentType = candidate
|
|
root = found
|
|
break
|
|
}
|
|
}
|
|
if (!root || !documentType) {
|
|
// A bare root: decide by the line element present.
|
|
if (ublChildren(envelope, 'InvoiceLine').length) {
|
|
documentType = 'Invoice'
|
|
root = envelope
|
|
} else if (ublChildren(envelope, 'CreditNoteLine').length) {
|
|
documentType = 'CreditNote'
|
|
root = envelope
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const warnings: string[] = []
|
|
const documentId = ublText(root, 'ID')
|
|
if (!documentId) warnings.push('document id missing')
|
|
|
|
const totalsNode = ublFirst(root, 'LegalMonetaryTotal')
|
|
const taxTotal = ublFirst(root, 'TaxTotal')
|
|
const taxSubtotals = ublChildren(taxTotal, 'TaxSubtotal').map((subtotal) => {
|
|
const category = ublFirst(subtotal, 'TaxCategory')
|
|
return {
|
|
taxableAmount: ublNumber(subtotal, 'TaxableAmount'),
|
|
taxAmount: ublNumber(subtotal, 'TaxAmount'),
|
|
categoryId: ublText(category, 'ID'),
|
|
percent: ublNumber(category, 'Percent'),
|
|
exemptionReason: ublText(category, 'TaxExemptionReason'),
|
|
}
|
|
})
|
|
|
|
const lines = readLines(root, documentType === 'Invoice' ? 'InvoiceLine' : 'CreditNoteLine')
|
|
if (lines.length === 0) warnings.push('no lines')
|
|
|
|
const totals: PeppolInboundTotals = {
|
|
lineExtension: ublNumber(totalsNode, 'LineExtensionAmount'),
|
|
taxExclusive: ublNumber(totalsNode, 'TaxExclusiveAmount'),
|
|
taxInclusive: ublNumber(totalsNode, 'TaxInclusiveAmount'),
|
|
allowanceTotal: ublNumber(totalsNode, 'AllowanceTotalAmount'),
|
|
chargeTotal: ublNumber(totalsNode, 'ChargeTotalAmount'),
|
|
prepaid: ublNumber(totalsNode, 'PrepaidAmount'),
|
|
payableRounding: ublNumber(totalsNode, 'PayableRoundingAmount'),
|
|
payable: ublNumber(totalsNode, 'PayableAmount'),
|
|
taxAmount: ublNumber(taxTotal, 'TaxAmount'),
|
|
}
|
|
if (totals.payable === null) warnings.push('payable amount missing')
|
|
|
|
return {
|
|
documentType,
|
|
customizationId: ublText(root, 'CustomizationID'),
|
|
profileId: ublText(root, 'ProfileID'),
|
|
documentId: documentId ?? '',
|
|
issueDate: ublText(root, 'IssueDate'),
|
|
dueDate: ublText(root, 'DueDate') ?? ublText(ublFirst(root, 'PaymentMeans'), 'PaymentDueDate'),
|
|
typeCode: ublText(root, documentType === 'Invoice' ? 'InvoiceTypeCode' : 'CreditNoteTypeCode'),
|
|
currency: ublText(root, 'DocumentCurrencyCode'),
|
|
buyerReference: ublText(root, 'BuyerReference'),
|
|
orderReference: ublText(ublFirst(root, 'OrderReference'), 'ID'),
|
|
billingReferences: ublChildren(root, 'BillingReference')
|
|
.map((reference) => ublText(ublFirst(reference, 'InvoiceDocumentReference'), 'ID'))
|
|
.filter((id): id is string => !!id),
|
|
note: ublText(root, 'Note'),
|
|
paymentTermsNote: ublText(ublFirst(root, 'PaymentTerms'), 'Note'),
|
|
supplier: readParty(root, 'AccountingSupplierParty', warnings),
|
|
customer: readParty(root, 'AccountingCustomerParty', warnings),
|
|
paymentMeans: readPaymentMeans(root),
|
|
totals,
|
|
taxSubtotals,
|
|
lines,
|
|
attachments: readAttachments(root),
|
|
warnings,
|
|
}
|
|
}
|