feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
99a872987e
commit
f93152c397
@@ -290,6 +290,15 @@ function looksLikeXhtml(bytes: Uint8Array): boolean {
|
||||
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
|
||||
}
|
||||
|
||||
/** UBL and other XML payloads: an XML declaration or an element root after an optional BOM. */
|
||||
function looksLikeXml(bytes: Uint8Array): boolean {
|
||||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||||
const head = Buffer.from(bytes.slice(offset, offset + 256))
|
||||
.toString('utf8')
|
||||
.replace(/^[\s\uFEFF]+/, '')
|
||||
return head.startsWith('<?xml') || /^<[A-Za-z_][\w.:-]*/.test(head)
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON has no binary magic number either. For the declared type
|
||||
* application/json (raw PSD2 responses archived as räkenskapsinformation per
|
||||
@@ -320,6 +329,13 @@ export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType
|
||||
if (looksLikeXhtml(new Uint8Array(buffer))) return null
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
|
||||
}
|
||||
// Received Peppol e-invoices are archived as the exact UBL XML (the
|
||||
// räkenskapsinformation is the XML itself). Same shape check as XHTML: an
|
||||
// XML declaration or an element root, never loosened for binary types.
|
||||
if (declaredMimeType === 'application/xml' || declaredMimeType === 'text/xml') {
|
||||
if (looksLikeXml(new Uint8Array(buffer))) return null
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XML-dokument.`
|
||||
}
|
||||
// HTML mail underlag from the invoice-inbox inbound pipeline. Same
|
||||
// doctype/root-element check as XHTML: the pipeline wraps fragment-shaped
|
||||
// mail bodies in a full document shell before upload.
|
||||
|
||||
@@ -1294,6 +1294,42 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Peppol-operatören kunde inte nås just nu. Fakturan har inte skickats; försök igen om en stund.',
|
||||
message_en: 'The Peppol access point could not be reached. The invoice has not been sent; try again shortly.',
|
||||
},
|
||||
// /api/settings/peppol: publishing a company's identifier for receiving.
|
||||
PEPPOL_RECEIVING_UNSUPPORTED: {
|
||||
httpStatus: 503,
|
||||
message_sv: 'Den konfigurerade Peppol-operatören stöder inte mottagning av e-fakturor.',
|
||||
message_en: 'The configured Peppol access point does not support receiving e-invoices.',
|
||||
},
|
||||
PEPPOL_SANDBOX_NOT_ALLOWED: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Peppol-registrering är inte tillgänglig i demobolaget. Skapa ett riktigt konto för att ta emot e-fakturor.',
|
||||
message_en: 'Peppol registration is not available in the demo company. Create a real account to receive e-invoices.',
|
||||
},
|
||||
PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED: {
|
||||
httpStatus: 422,
|
||||
message_sv: 'Bolaget behöver ett giltigt organisationsnummer i företagsinställningarna innan det kan ta emot e-fakturor via Peppol.',
|
||||
message_en: 'The company needs a valid organisation number in company settings before it can receive e-invoices via Peppol.',
|
||||
},
|
||||
PEPPOL_REGISTRATION_PERSONAL_NUMBER: {
|
||||
httpStatus: 422,
|
||||
message_sv: 'Enskild firma med personnummer kan ännu inte registreras för Peppol: det skulle publicera personuppgifter i Peppol-katalogen. Stöd för GLN-nummer kommer.',
|
||||
message_en: 'A sole trader identified by a personal identity number cannot be registered for Peppol yet: it would publish personal data in the Peppol directory. GLN support is coming.',
|
||||
},
|
||||
PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED: {
|
||||
httpStatus: 422,
|
||||
message_sv: 'Bolaget behöver ett företagsnamn i företagsinställningarna innan det kan registreras för Peppol.',
|
||||
message_en: 'The company needs a company name in company settings before it can be registered for Peppol.',
|
||||
},
|
||||
PEPPOL_REGISTRATION_FAILED: {
|
||||
httpStatus: 502,
|
||||
message_sv: 'Peppol-operatören kunde inte genomföra registreringen. Försök igen om en stund.',
|
||||
message_en: 'The Peppol access point could not complete the registration. Try again shortly.',
|
||||
},
|
||||
PEPPOL_REGISTRATION_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Bolaget är inte registrerat för Peppol-mottagning.',
|
||||
message_en: 'The company is not registered for Peppol receiving.',
|
||||
},
|
||||
}
|
||||
|
||||
const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
{
|
||||
"Invoice": {
|
||||
"$": {
|
||||
"xmlns": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
|
||||
"xmlns:cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
|
||||
"xmlns:cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
|
||||
"xmlns:ccts": "urn:un:unece:uncefact:documentation:2",
|
||||
"xmlns:qdt": "urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2",
|
||||
"xmlns:udt": "urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2"
|
||||
},
|
||||
"cbc:CustomizationID": [
|
||||
{
|
||||
"_": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
|
||||
}
|
||||
],
|
||||
"cbc:ProfileID": [
|
||||
{
|
||||
"_": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
|
||||
}
|
||||
],
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "20267497"
|
||||
}
|
||||
],
|
||||
"cbc:IssueDate": [
|
||||
{
|
||||
"_": "2026-08-21"
|
||||
}
|
||||
],
|
||||
"cbc:DueDate": [
|
||||
{
|
||||
"_": "2026-09-20"
|
||||
}
|
||||
],
|
||||
"cbc:InvoiceTypeCode": [
|
||||
{
|
||||
"_": "380"
|
||||
}
|
||||
],
|
||||
"cbc:DocumentCurrencyCode": [
|
||||
{
|
||||
"_": "SEK"
|
||||
}
|
||||
],
|
||||
"cbc:BuyerReference": [
|
||||
{
|
||||
"_": "Test kundreferens"
|
||||
}
|
||||
],
|
||||
"cac:AccountingSupplierParty": [
|
||||
{
|
||||
"cac:Party": [
|
||||
{
|
||||
"cbc:EndpointID": [
|
||||
{
|
||||
"_": "5567321707",
|
||||
"$": {
|
||||
"schemeID": "0007"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cac:PartyName": [
|
||||
{
|
||||
"cbc:Name": [
|
||||
{
|
||||
"_": "Qvalia AB"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PostalAddress": [
|
||||
{
|
||||
"cbc:StreetName": [
|
||||
{
|
||||
"_": "Wallingatan 33 3tr"
|
||||
}
|
||||
],
|
||||
"cbc:CityName": [
|
||||
{
|
||||
"_": "Stockholm"
|
||||
}
|
||||
],
|
||||
"cbc:PostalZone": [
|
||||
{
|
||||
"_": "11124"
|
||||
}
|
||||
],
|
||||
"cac:Country": [
|
||||
{
|
||||
"cbc:IdentificationCode": [
|
||||
{
|
||||
"_": "SE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PartyTaxScheme": [
|
||||
{
|
||||
"cbc:CompanyID": [
|
||||
{
|
||||
"_": "SE556732170701"
|
||||
}
|
||||
],
|
||||
"cac:TaxScheme": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "VAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cbc:CompanyID": [
|
||||
{
|
||||
"_": "Godkänd för F-skatt"
|
||||
}
|
||||
],
|
||||
"cac:TaxScheme": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "TAX"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PartyLegalEntity": [
|
||||
{
|
||||
"cbc:RegistrationName": [
|
||||
{
|
||||
"_": "Qvalia AB"
|
||||
}
|
||||
],
|
||||
"cbc:CompanyID": [
|
||||
{
|
||||
"_": "5567321707"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:Contact": [
|
||||
{
|
||||
"cbc:Name": [
|
||||
{
|
||||
"_": "Qvalia AB"
|
||||
}
|
||||
],
|
||||
"cbc:Telephone": [
|
||||
{
|
||||
"_": "0812010850"
|
||||
}
|
||||
],
|
||||
"cbc:ElectronicMail": [
|
||||
{
|
||||
"_": "help@qvalia.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:AccountingCustomerParty": [
|
||||
{
|
||||
"cac:Party": [
|
||||
{
|
||||
"cbc:EndpointID": [
|
||||
{
|
||||
"_": "5595386219",
|
||||
"$": {
|
||||
"schemeID": "0007"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cac:PartyIdentification": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "5595386219"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PartyName": [
|
||||
{
|
||||
"cbc:Name": [
|
||||
{
|
||||
"_": "Arcim Technology AB"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PostalAddress": [
|
||||
{
|
||||
"cbc:StreetName": [
|
||||
{
|
||||
"_": "Ostermalmsgatan 26A"
|
||||
}
|
||||
],
|
||||
"cbc:CityName": [
|
||||
{
|
||||
"_": "Stockholm"
|
||||
}
|
||||
],
|
||||
"cbc:PostalZone": [
|
||||
{
|
||||
"_": "11426"
|
||||
}
|
||||
],
|
||||
"cac:Country": [
|
||||
{
|
||||
"cbc:IdentificationCode": [
|
||||
{
|
||||
"_": "SE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PartyTaxScheme": [
|
||||
{
|
||||
"cbc:CompanyID": [
|
||||
{
|
||||
"_": "SE559538621901"
|
||||
}
|
||||
],
|
||||
"cac:TaxScheme": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "VAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PartyLegalEntity": [
|
||||
{
|
||||
"cbc:RegistrationName": [
|
||||
{
|
||||
"_": "Arcim Technology AB"
|
||||
}
|
||||
],
|
||||
"cbc:CompanyID": [
|
||||
{
|
||||
"_": "5595386219"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:Contact": [
|
||||
{
|
||||
"cbc:Name": [
|
||||
{
|
||||
"_": "Test kundreferens"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:PaymentMeans": [
|
||||
{
|
||||
"cbc:PaymentMeansCode": [
|
||||
{
|
||||
"_": "30"
|
||||
}
|
||||
],
|
||||
"cac:PayeeFinancialAccount": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "12344321"
|
||||
}
|
||||
],
|
||||
"cac:FinancialInstitutionBranch": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "BBAN"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:TaxTotal": [
|
||||
{
|
||||
"cbc:TaxAmount": [
|
||||
{
|
||||
"_": "12",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cac:TaxSubtotal": [
|
||||
{
|
||||
"cbc:TaxableAmount": [
|
||||
{
|
||||
"_": "100",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:TaxAmount": [
|
||||
{
|
||||
"_": "12",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cac:TaxCategory": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "S"
|
||||
}
|
||||
],
|
||||
"cbc:Percent": [
|
||||
{
|
||||
"_": "12"
|
||||
}
|
||||
],
|
||||
"cac:TaxScheme": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "VAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:LegalMonetaryTotal": [
|
||||
{
|
||||
"cbc:LineExtensionAmount": [
|
||||
{
|
||||
"_": "100",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:TaxExclusiveAmount": [
|
||||
{
|
||||
"_": "100",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:TaxInclusiveAmount": [
|
||||
{
|
||||
"_": "112",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:ChargeTotalAmount": [
|
||||
{
|
||||
"_": "0",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:PayableRoundingAmount": [
|
||||
{
|
||||
"_": "0",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:PayableAmount": [
|
||||
{
|
||||
"_": "112",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:InvoiceLine": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "1"
|
||||
}
|
||||
],
|
||||
"cbc:InvoicedQuantity": [
|
||||
{
|
||||
"_": "1",
|
||||
"$": {
|
||||
"unitCode": "EA"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cbc:LineExtensionAmount": [
|
||||
{
|
||||
"_": "100",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cac:Item": [
|
||||
{
|
||||
"cbc:Name": [
|
||||
{
|
||||
"_": "New test item"
|
||||
}
|
||||
],
|
||||
"cac:SellersItemIdentification": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "123789"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:ClassifiedTaxCategory": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "S"
|
||||
}
|
||||
],
|
||||
"cbc:Percent": [
|
||||
{
|
||||
"_": "12"
|
||||
}
|
||||
],
|
||||
"cac:TaxScheme": [
|
||||
{
|
||||
"cbc:ID": [
|
||||
{
|
||||
"_": "VAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cac:Price": [
|
||||
{
|
||||
"cbc:PriceAmount": [
|
||||
{
|
||||
"_": "100",
|
||||
"$": {
|
||||
"currencyID": "SEK"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"integrationId": "a5845a11-4e5a-4700-bca3-e670a6cd8a79"
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseUblJsonDocument,
|
||||
swedishOrgNumberFrom,
|
||||
ublAttr,
|
||||
ublChildren,
|
||||
ublText,
|
||||
} from '@/lib/invoices/peppol-inbound-ubl'
|
||||
|
||||
/** Qvalia's live UBL-JSON for the first inbound sandbox invoice (contact data sanitized). */
|
||||
const QVALIA_MESSAGE = JSON.parse(
|
||||
readFileSync(join(__dirname, 'fixtures', 'qvalia-inbound-invoice.json'), 'utf8'),
|
||||
) as unknown
|
||||
|
||||
describe('parseUblJsonDocument', () => {
|
||||
it('reads the Qvalia inbound invoice end to end', () => {
|
||||
const doc = parseUblJsonDocument(QVALIA_MESSAGE)
|
||||
expect(doc).not.toBeNull()
|
||||
if (!doc) return
|
||||
|
||||
expect(doc.documentType).toBe('Invoice')
|
||||
expect(doc.documentId).toBe('20267497')
|
||||
expect(doc.customizationId).toBe('urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0')
|
||||
expect(doc.issueDate).toBe('2026-08-21')
|
||||
expect(doc.dueDate).toBe('2026-09-20')
|
||||
expect(doc.typeCode).toBe('380')
|
||||
expect(doc.currency).toBe('SEK')
|
||||
expect(doc.buyerReference).toBe('Test kundreferens')
|
||||
|
||||
expect(doc.supplier).toMatchObject({
|
||||
name: 'Qvalia AB',
|
||||
endpoint: { scheme: '0007', identifier: '5567321707' },
|
||||
legalCompanyId: '5567321707',
|
||||
orgNumber: '5567321707',
|
||||
vatNumber: 'SE556732170701',
|
||||
street: 'Wallingatan 33 3tr',
|
||||
city: 'Stockholm',
|
||||
postalZone: '11124',
|
||||
countryCode: 'SE',
|
||||
email: 'help@qvalia.com',
|
||||
})
|
||||
expect(doc.customer).toMatchObject({
|
||||
name: 'Arcim Technology AB',
|
||||
endpoint: { scheme: '0007', identifier: '5595386219' },
|
||||
orgNumber: '5595386219',
|
||||
})
|
||||
|
||||
expect(doc.paymentMeans).toHaveLength(1)
|
||||
expect(doc.paymentMeans[0]).toMatchObject({
|
||||
code: '30',
|
||||
accountId: '12344321',
|
||||
branchId: 'BBAN',
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
iban: null,
|
||||
})
|
||||
|
||||
expect(doc.totals).toEqual({
|
||||
lineExtension: 100,
|
||||
taxExclusive: 100,
|
||||
taxInclusive: 112,
|
||||
allowanceTotal: null,
|
||||
chargeTotal: 0,
|
||||
prepaid: null,
|
||||
payableRounding: 0,
|
||||
payable: 112,
|
||||
taxAmount: 12,
|
||||
})
|
||||
expect(doc.taxSubtotals).toEqual([
|
||||
{ taxableAmount: 100, taxAmount: 12, categoryId: 'S', percent: 12, exemptionReason: null },
|
||||
])
|
||||
expect(doc.lines).toEqual([{
|
||||
id: '1',
|
||||
name: 'New test item',
|
||||
description: null,
|
||||
quantity: 1,
|
||||
unitCode: 'EA',
|
||||
priceAmount: 100,
|
||||
lineExtensionAmount: 100,
|
||||
vatCategoryId: 'S',
|
||||
vatPercent: 12,
|
||||
sellersItemId: '123789',
|
||||
buyerAccountingCost: null,
|
||||
}])
|
||||
expect(doc.attachments).toEqual([])
|
||||
expect(doc.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('reads the OASIS UBL-JSON shape (bare keys, attributes beside the text) and Swedish giro branches', () => {
|
||||
const doc = parseUblJsonDocument({
|
||||
Invoice: {
|
||||
ID: [{ _: 'F-1' }],
|
||||
IssueDate: [{ _: '2026-08-01' }],
|
||||
DocumentCurrencyCode: [{ _: 'SEK' }],
|
||||
AccountingSupplierParty: [{ Party: [{
|
||||
EndpointID: [{ _: '5560160680', schemeID: '0007' }],
|
||||
PartyLegalEntity: [{ RegistrationName: [{ _: 'Säljare AB' }], CompanyID: [{ _: '556016-0680' }] }],
|
||||
}] }],
|
||||
AccountingCustomerParty: [{ Party: [{ EndpointID: [{ _: '5595386219', schemeID: '0007' }] }] }],
|
||||
PaymentMeans: [
|
||||
{ PaymentMeansCode: [{ _: '30' }], PaymentID: [{ _: '123456789' }], PayeeFinancialAccount: [{ ID: [{ _: '991-2346' }], FinancialInstitutionBranch: [{ ID: [{ _: 'SE:BANKGIRO' }] }] }] },
|
||||
{ PaymentMeansCode: [{ _: '58' }], PayeeFinancialAccount: [{ ID: [{ _: 'SE45 5000 0000 0583 9825 7466' }] }] },
|
||||
],
|
||||
LegalMonetaryTotal: [{ PayableAmount: [{ _: '125.50', currencyID: 'SEK' }] }],
|
||||
InvoiceLine: [{ ID: [{ _: '1' }], InvoicedQuantity: [{ _: '2', unitCode: 'HUR' }], LineExtensionAmount: [{ _: '100' }], Item: [{ Name: [{ _: 'Rådgivning' }] }] }],
|
||||
},
|
||||
})
|
||||
expect(doc?.supplier.orgNumber).toBe('5560160680')
|
||||
expect(doc?.paymentMeans[0]).toMatchObject({ paymentId: '123456789', bankgiro: '9912346', branchId: 'SE:BANKGIRO' })
|
||||
expect(doc?.paymentMeans[1].iban).toBe('SE4550000000058398257466')
|
||||
expect(doc?.totals.payable).toBe(125.5)
|
||||
expect(doc?.lines[0]).toMatchObject({ quantity: 2, unitCode: 'HUR', name: 'Rådgivning' })
|
||||
expect(doc?.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('recognizes credit notes, billing references and embedded attachments', () => {
|
||||
const doc = parseUblJsonDocument({
|
||||
'CreditNote': {
|
||||
'cbc:ID': [{ _: 'K-9' }],
|
||||
'cac:BillingReference': [{ 'cac:InvoiceDocumentReference': [{ 'cbc:ID': [{ _: 'F-1' }] }] }],
|
||||
'cac:AdditionalDocumentReference': [{
|
||||
'cbc:ID': [{ _: 'spec' }],
|
||||
'cac:Attachment': [{ 'cbc:EmbeddedDocumentBinaryObject': [{ _: 'UjBsR09E', $: { mimeCode: 'application/pdf', filename: 'spec.pdf' } }] }],
|
||||
}],
|
||||
'cac:AccountingSupplierParty': [{ 'cac:Party': [{ 'cbc:EndpointID': [{ _: '1', $: { schemeID: '0088' } }] }] }],
|
||||
'cac:AccountingCustomerParty': [{ 'cac:Party': [{}] }],
|
||||
'cac:LegalMonetaryTotal': [{ 'cbc:PayableAmount': [{ _: '-100' }] }],
|
||||
'cac:CreditNoteLine': [{ 'cbc:ID': [{ _: '1' }], 'cbc:CreditedQuantity': [{ _: '1' }] }],
|
||||
},
|
||||
})
|
||||
expect(doc?.documentType).toBe('CreditNote')
|
||||
expect(doc?.billingReferences).toEqual(['F-1'])
|
||||
expect(doc?.attachments).toEqual([{
|
||||
id: 'spec', description: null, filename: 'spec.pdf', mimeType: 'application/pdf', base64: 'UjBsR09E', externalUri: null,
|
||||
}])
|
||||
expect(doc?.supplier.orgNumber).toBeNull()
|
||||
expect(doc?.totals.payable).toBe(-100)
|
||||
})
|
||||
|
||||
it('returns null for non-UBL input and records warnings instead of throwing on thin documents', () => {
|
||||
expect(parseUblJsonDocument(null)).toBeNull()
|
||||
expect(parseUblJsonDocument({ Order: {} })).toBeNull()
|
||||
const thin = parseUblJsonDocument({ Invoice: { 'cac:InvoiceLine': [] , 'cbc:ID': [{ _: 'X' }] } })
|
||||
expect(thin?.documentId).toBe('X')
|
||||
expect(thin?.warnings).toEqual(expect.arrayContaining(['no lines', 'payable amount missing']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('ubl helpers', () => {
|
||||
it('read prefixed and bare keys, attributes in both placements, and org numbers', () => {
|
||||
const node = { 'cbc:ID': [{ _: 'a', $: { schemeID: 'x' } }], Name: 'plain' }
|
||||
expect(ublText(node, 'ID')).toBe('a')
|
||||
expect(ublAttr(ublChildren(node, 'ID')[0], 'schemeID')).toBe('x')
|
||||
expect(ublText(node, 'Name')).toBe('plain')
|
||||
expect(swedishOrgNumberFrom('556016-0680')).toBe('5560160680')
|
||||
expect(swedishOrgNumberFrom('165560160680')).toBe('5560160680')
|
||||
expect(swedishOrgNumberFrom('12', null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
archiveInboundPeppolMessage,
|
||||
processInboundPeppolRow,
|
||||
syncInboundPeppolDocuments,
|
||||
type PeppolInboundRow,
|
||||
} from '@/lib/invoices/peppol-inbound'
|
||||
import { parseUblJsonDocument } from '@/lib/invoices/peppol-inbound-ubl'
|
||||
import type { PeppolInboundMessage, PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const QVALIA_MESSAGE = JSON.parse(
|
||||
readFileSync(join(__dirname, 'fixtures', 'qvalia-inbound-invoice.json'), 'utf8'),
|
||||
) as Record<string, unknown>
|
||||
|
||||
const { supabase: mockService, enqueue, reset, calls } = createQueuedMockSupabase()
|
||||
const service = mockService as unknown as SupabaseClient
|
||||
const log = createLogger('test')
|
||||
|
||||
const message: PeppolInboundMessage = {
|
||||
provider: 'qvalia',
|
||||
providerDocumentId: 'a5845a11-4e5a-4700-bca3-e670a6cd8a79',
|
||||
documentType: 'Invoice',
|
||||
payload: QVALIA_MESSAGE,
|
||||
receivedAt: '2026-08-21T13:55:00.000Z',
|
||||
}
|
||||
|
||||
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
|
||||
return {
|
||||
provider: 'qvalia',
|
||||
lookupRecipient: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
verifyWebhook: vi.fn(),
|
||||
retrieveEvidence: vi.fn(),
|
||||
listInboundDocuments: vi.fn().mockImplementation(async ({ documentType }: { documentType: string }) =>
|
||||
documentType === 'Invoice' ? [message] : []),
|
||||
fetchInboundDocumentXml: vi.fn().mockResolvedValue('<Invoice><cbc:ID>20267497</cbc:ID></Invoice>'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function row(overrides: Partial<PeppolInboundRow> = {}): PeppolInboundRow {
|
||||
return {
|
||||
id: 'doc-1',
|
||||
provider: 'qvalia',
|
||||
provider_document_id: message.providerDocumentId,
|
||||
document_type: 'Invoice',
|
||||
document_id: '20267497',
|
||||
issue_date: '2026-08-21',
|
||||
due_date: '2026-09-20',
|
||||
currency: 'SEK',
|
||||
payable_amount: 112,
|
||||
sender_scheme: '0007',
|
||||
sender_identifier: '5567321707',
|
||||
sender_name: 'Qvalia AB',
|
||||
recipient_scheme: '0007',
|
||||
recipient_identifier: '5595386219',
|
||||
company_id: null,
|
||||
status: 'received',
|
||||
inbox_item_id: null,
|
||||
supplier_invoice_id: null,
|
||||
xml_document_id: null,
|
||||
xml_payload: '<Invoice/>',
|
||||
xml_sha256: 'a'.repeat(64),
|
||||
ubl_json: QVALIA_MESSAGE,
|
||||
summary: {},
|
||||
received_at: '2026-08-21T13:55:00.000Z',
|
||||
processed_at: null,
|
||||
last_error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('archiveInboundPeppolMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('archives a new message with the exact XML, parsed header fields and the JSON payload', async () => {
|
||||
const transport = makeTransport()
|
||||
enqueue({ data: null, error: null }) // no existing row
|
||||
enqueue({ data: row({ status: 'received' }), error: null }) // insert
|
||||
const result = await archiveInboundPeppolMessage({ service, transport, message, log })
|
||||
|
||||
expect(result.created).toBe(true)
|
||||
expect(result.document?.documentId).toBe('20267497')
|
||||
const inserted = calls.find((c) => c.method === 'insert')?.args[0] as Record<string, unknown>
|
||||
expect(inserted).toMatchObject({
|
||||
provider: 'qvalia',
|
||||
provider_document_id: message.providerDocumentId,
|
||||
document_type: 'Invoice',
|
||||
document_id: '20267497',
|
||||
issue_date: '2026-08-21',
|
||||
due_date: '2026-09-20',
|
||||
currency: 'SEK',
|
||||
payable_amount: 112,
|
||||
sender_scheme: '0007',
|
||||
sender_identifier: '5567321707',
|
||||
sender_name: 'Qvalia AB',
|
||||
recipient_scheme: '0007',
|
||||
recipient_identifier: '5595386219',
|
||||
status: 'received',
|
||||
xml_payload: '<Invoice><cbc:ID>20267497</cbc:ID></Invoice>',
|
||||
received_at: '2026-08-21T13:55:00.000Z',
|
||||
})
|
||||
expect(inserted.xml_sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(transport.fetchInboundDocumentXml).toHaveBeenCalledWith(message.providerDocumentId, 'Invoice')
|
||||
})
|
||||
|
||||
it('returns the stored row for a message seen before and never re-fetches', async () => {
|
||||
const transport = makeTransport()
|
||||
enqueue({ data: row({ status: 'converted', company_id: 'company-1' }), error: null })
|
||||
const result = await archiveInboundPeppolMessage({ service, transport, message, log })
|
||||
expect(result.created).toBe(false)
|
||||
expect(result.row.status).toBe('converted')
|
||||
expect(transport.fetchInboundDocumentXml).not.toHaveBeenCalled()
|
||||
expect(calls.some((c) => c.method === 'insert')).toBe(false)
|
||||
})
|
||||
|
||||
it('archives the JSON even when the XML fetch fails, so nothing is lost', async () => {
|
||||
const transport = makeTransport({ fetchInboundDocumentXml: vi.fn().mockRejectedValue(new Error('timeout')) })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: row({ xml_payload: null, xml_sha256: null }), error: null })
|
||||
const result = await archiveInboundPeppolMessage({ service, transport, message, log })
|
||||
expect(result.created).toBe(true)
|
||||
const inserted = calls.find((c) => c.method === 'insert')?.args[0] as Record<string, unknown>
|
||||
expect(inserted.xml_payload).toBeNull()
|
||||
expect(inserted.ubl_json).toBe(QVALIA_MESSAGE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processInboundPeppolRow', () => {
|
||||
const document = parseUblJsonDocument(QVALIA_MESSAGE)!
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('routes to the registered company and delivers to the inbox', async () => {
|
||||
const deliver = vi.fn().mockResolvedValue({ inboxItemId: 'inbox-1', xmlDocumentId: 'doc-xml-1' })
|
||||
enqueue({ data: { company_id: 'company-1' }, error: null }) // registration lookup
|
||||
enqueue({ data: row({ company_id: 'company-1', status: 'routed' }), error: null }) // route update
|
||||
enqueue({ data: row({ company_id: 'company-1', status: 'converted', inbox_item_id: 'inbox-1' }), error: null })
|
||||
|
||||
const result = await processInboundPeppolRow({ service, row: row(), document, deliver, log })
|
||||
|
||||
expect(result.outcome).toBe('delivered')
|
||||
expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company-1', document }))
|
||||
const updates = calls.filter((c) => c.method === 'update').map((c) => c.args[0] as Record<string, unknown>)
|
||||
expect(updates[0]).toMatchObject({ company_id: 'company-1', status: 'routed' })
|
||||
expect(updates[1]).toMatchObject({ status: 'converted', inbox_item_id: 'inbox-1', xml_document_id: 'doc-xml-1' })
|
||||
})
|
||||
|
||||
it('marks a document for an unregistered recipient as unrouted, and never delivers it', async () => {
|
||||
const deliver = vi.fn()
|
||||
enqueue({ data: null, error: null }) // no registration
|
||||
enqueue({ data: row({ status: 'unrouted' }), error: null })
|
||||
const result = await processInboundPeppolRow({ service, row: row(), document, deliver, log })
|
||||
expect(result.outcome).toBe('unrouted')
|
||||
expect(deliver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a failed delivery with the reason and leaves the row retryable', async () => {
|
||||
const deliver = vi.fn().mockRejectedValue(new Error('storage down'))
|
||||
enqueue({ data: row({ company_id: 'company-1', status: 'failed', last_error: 'storage down' }), error: null })
|
||||
const result = await processInboundPeppolRow({
|
||||
service, row: row({ company_id: 'company-1', status: 'routed' }), document, deliver, log,
|
||||
})
|
||||
expect(result.outcome).toBe('failed')
|
||||
const update = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
||||
expect(update).toMatchObject({ status: 'failed', last_error: 'storage down' })
|
||||
})
|
||||
|
||||
it('skips rows that are already converted or ignored', async () => {
|
||||
const deliver = vi.fn()
|
||||
const result = await processInboundPeppolRow({
|
||||
service, row: row({ company_id: 'company-1', status: 'converted' }), document, deliver, log,
|
||||
})
|
||||
expect(result.outcome).toBe('skipped')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncInboundPeppolDocuments', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('lists invoices and credit notes, archives, routes and delivers, and counts the outcome', async () => {
|
||||
const transport = makeTransport()
|
||||
const deliver = vi.fn().mockResolvedValue({ inboxItemId: 'inbox-1', xmlDocumentId: null })
|
||||
enqueue({ data: null, error: null }) // no existing archive row
|
||||
enqueue({ data: row(), error: null }) // insert
|
||||
enqueue({ data: { company_id: 'company-1' }, error: null }) // registration
|
||||
enqueue({ data: row({ company_id: 'company-1', status: 'routed' }), error: null })
|
||||
enqueue({ data: row({ company_id: 'company-1', status: 'converted' }), error: null })
|
||||
|
||||
const summary = await syncInboundPeppolDocuments({ service, transport, deliver, log })
|
||||
|
||||
expect(transport.listInboundDocuments).toHaveBeenCalledTimes(2)
|
||||
expect(summary).toMatchObject({ listed: 1, archived: 1, duplicates: 0, delivered: 1, failed: 0, unrouted: 0 })
|
||||
expect(summary.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps going when the provider listing fails for one document type', async () => {
|
||||
const transport = makeTransport({
|
||||
listInboundDocuments: vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Qvalia answered 503'))
|
||||
.mockResolvedValueOnce([]),
|
||||
})
|
||||
const summary = await syncInboundPeppolDocuments({ service, transport, deliver: null, log })
|
||||
expect(summary.errors).toEqual([{ providerDocumentId: 'list:Invoice', reason: 'Qvalia answered 503' }])
|
||||
expect(transport.listInboundDocuments).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('is a no-op for a send-only transport', async () => {
|
||||
const transport = makeTransport({ listInboundDocuments: undefined })
|
||||
const summary = await syncInboundPeppolDocuments({ service, transport, deliver: null, log })
|
||||
expect(summary.listed).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { parseUblJsonDocument } from '@/lib/invoices/peppol-inbound-ubl'
|
||||
import type { PeppolInboundRow } from '@/lib/invoices/peppol-inbound'
|
||||
|
||||
const uploadDocumentMock = vi.fn()
|
||||
const matchSupplierIdMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => uploadDocumentMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/suppliers/match-supplier', () => ({
|
||||
matchSupplierId: (...args: unknown[]) => matchSupplierIdMock(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
deliverPeppolDocumentToInbox,
|
||||
peppolDocumentToExtraction,
|
||||
} from '@/lib/invoices/peppol-inbox-delivery'
|
||||
|
||||
const QVALIA_MESSAGE = JSON.parse(
|
||||
readFileSync(join(__dirname, 'fixtures', 'qvalia-inbound-invoice.json'), 'utf8'),
|
||||
) as Record<string, unknown>
|
||||
const document = parseUblJsonDocument(QVALIA_MESSAGE)!
|
||||
const XML = '<Invoice><cbc:ID>20267497</cbc:ID></Invoice>'
|
||||
|
||||
const { supabase: mockService, enqueue, reset, calls } = createQueuedMockSupabase()
|
||||
const service = mockService as unknown as SupabaseClient
|
||||
|
||||
function row(overrides: Partial<PeppolInboundRow> = {}): PeppolInboundRow {
|
||||
return {
|
||||
id: 'doc-1',
|
||||
provider: 'qvalia',
|
||||
provider_document_id: 'a5845a11-4e5a-4700-bca3-e670a6cd8a79',
|
||||
document_type: 'Invoice',
|
||||
document_id: '20267497',
|
||||
issue_date: '2026-08-21',
|
||||
due_date: '2026-09-20',
|
||||
currency: 'SEK',
|
||||
payable_amount: 112,
|
||||
sender_scheme: '0007',
|
||||
sender_identifier: '5567321707',
|
||||
sender_name: 'Qvalia AB',
|
||||
recipient_scheme: '0007',
|
||||
recipient_identifier: '5595386219',
|
||||
company_id: 'company-1',
|
||||
status: 'routed',
|
||||
inbox_item_id: null,
|
||||
supplier_invoice_id: null,
|
||||
xml_document_id: null,
|
||||
xml_payload: XML,
|
||||
xml_sha256: 'a'.repeat(64),
|
||||
ubl_json: QVALIA_MESSAGE,
|
||||
summary: {},
|
||||
received_at: '2026-08-21T13:55:00.000Z',
|
||||
processed_at: null,
|
||||
last_error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('peppolDocumentToExtraction', () => {
|
||||
it('maps the Qvalia invoice onto the inbox extraction shape without a model', () => {
|
||||
const extracted = peppolDocumentToExtraction(document)
|
||||
expect(extracted).toMatchObject({
|
||||
documentKind: 'supplier_invoice',
|
||||
supplier: {
|
||||
name: 'Qvalia AB',
|
||||
orgNumber: '5567321707',
|
||||
vatNumber: 'SE556732170701',
|
||||
address: 'Wallingatan 33 3tr, 11124 Stockholm',
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: '20267497',
|
||||
invoiceDate: '2026-08-21',
|
||||
dueDate: '2026-09-20',
|
||||
paymentReference: null,
|
||||
currency: 'SEK',
|
||||
},
|
||||
totals: { subtotal: 100, vatAmount: 12, total: 112, roundingAmount: null },
|
||||
vatBreakdown: [{ rate: 12, base: 100, amount: 12 }],
|
||||
confidence: 1,
|
||||
})
|
||||
expect(extracted.lineItems).toEqual([{
|
||||
description: 'New test item',
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
lineTotal: 100,
|
||||
vatRate: 12,
|
||||
accountSuggestion: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('formats Swedish giro numbers with a hyphen and negates credit notes', () => {
|
||||
const credit = {
|
||||
...document,
|
||||
documentType: 'CreditNote' as const,
|
||||
paymentMeans: [{ ...document.paymentMeans[0], bankgiro: '9912346', paymentId: '123456789' }],
|
||||
}
|
||||
const extracted = peppolDocumentToExtraction(credit)
|
||||
expect(extracted.supplier.bankgiro).toBe('991-2346')
|
||||
expect(extracted.invoice.paymentReference).toBe('123456789')
|
||||
expect(extracted.totals.total).toBe(-112)
|
||||
expect(extracted.lineItems[0].lineTotal).toBe(-100)
|
||||
expect(extracted.vatBreakdown[0]).toEqual({ rate: 12, base: -100, amount: -12 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deliverPeppolDocumentToInbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
uploadDocumentMock.mockImplementation(async (_s: unknown, _u: unknown, _c: unknown, file: { name: string }) => ({
|
||||
id: file.name.endsWith('.pdf') ? 'doc-pdf' : 'doc-xml',
|
||||
}))
|
||||
matchSupplierIdMock.mockResolvedValue('supplier-9')
|
||||
})
|
||||
|
||||
it('archives the exact XML, creates the inbox item with structured data and the Peppol channel context', async () => {
|
||||
enqueue({ data: null, error: null }) // no existing inbox item
|
||||
enqueue({ data: { user_id: 'user-reg' }, error: null }) // registration owner
|
||||
enqueue({ data: { id: 'inbox-1' }, error: null }) // inbox insert
|
||||
|
||||
const result = await deliverPeppolDocumentToInbox(service, { row: row(), companyId: 'company-1', document, xml: XML })
|
||||
|
||||
expect(result).toEqual({ inboxItemId: 'inbox-1', xmlDocumentId: 'doc-xml' })
|
||||
expect(uploadDocumentMock).toHaveBeenCalledTimes(1)
|
||||
const [, userId, companyId, file, metadata] = uploadDocumentMock.mock.calls[0]
|
||||
expect(userId).toBe('user-reg')
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(file).toMatchObject({ name: 'peppol-faktura-20267497.xml', type: 'application/xml' })
|
||||
expect(Buffer.from(file.buffer as ArrayBuffer).toString('utf8')).toBe(XML)
|
||||
expect(metadata).toEqual({ upload_source: 'e_invoice', dedupeByContent: true, extractionOwner: 'none' })
|
||||
expect(matchSupplierIdMock).toHaveBeenCalledWith(service, 'company-1', {
|
||||
orgNumber: '5567321707', vatNumber: 'SE556732170701', name: 'Qvalia AB',
|
||||
})
|
||||
|
||||
const inserted = calls.find((c) => c.method === 'insert')?.args[0] as Record<string, unknown>
|
||||
expect(inserted).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-reg',
|
||||
document_id: 'doc-xml',
|
||||
source: 'peppol',
|
||||
status: 'received',
|
||||
extraction_skipped: false,
|
||||
matched_supplier_id: 'supplier-9',
|
||||
email_from: 'Qvalia AB',
|
||||
channel_context: {
|
||||
channel: 'peppol',
|
||||
peppol_provider: 'qvalia',
|
||||
peppol_document_id: 'a5845a11-4e5a-4700-bca3-e670a6cd8a79',
|
||||
peppol_document_type: 'Invoice',
|
||||
peppol_sender_endpoint: '0007:5567321707',
|
||||
peppol_xml_document_id: 'doc-xml',
|
||||
},
|
||||
})
|
||||
expect((inserted.extracted_data as { confidence: number }).confidence).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers an embedded PDF as the inbox document and falls back to the first owner', async () => {
|
||||
const withPdf = {
|
||||
...document,
|
||||
attachments: [{ id: 'a1', description: null, filename: 'faktura.pdf', mimeType: 'application/pdf', base64: Buffer.from('%PDF-1.4').toString('base64'), externalUri: null }],
|
||||
}
|
||||
enqueue({ data: null, error: null }) // no existing item
|
||||
enqueue({ data: null, error: null }) // no registration user
|
||||
enqueue({ data: { user_id: 'user-owner' }, error: null }) // company owner
|
||||
enqueue({ data: { id: 'inbox-2' }, error: null })
|
||||
|
||||
const result = await deliverPeppolDocumentToInbox(service, { row: row(), companyId: 'company-1', document: withPdf, xml: XML })
|
||||
expect(result).toEqual({ inboxItemId: 'inbox-2', xmlDocumentId: 'doc-xml' })
|
||||
expect(uploadDocumentMock).toHaveBeenCalledTimes(2)
|
||||
expect(uploadDocumentMock.mock.calls[1][3]).toMatchObject({ name: 'faktura.pdf', type: 'application/pdf' })
|
||||
const inserted = calls.find((c) => c.method === 'insert')?.args[0] as Record<string, unknown>
|
||||
expect(inserted).toMatchObject({ document_id: 'doc-pdf', user_id: 'user-owner' })
|
||||
})
|
||||
|
||||
it('is idempotent: an existing inbox item for the provider document is returned, nothing re-archived', async () => {
|
||||
enqueue({ data: { id: 'inbox-1', document_id: 'doc-xml', channel_context: { channel: 'peppol', peppol_xml_document_id: 'doc-xml' } }, error: null })
|
||||
const result = await deliverPeppolDocumentToInbox(service, { row: row(), companyId: 'company-1', document, xml: XML })
|
||||
expect(result).toEqual({ inboxItemId: 'inbox-1', xmlDocumentId: 'doc-xml' })
|
||||
expect(uploadDocumentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a concurrent insert race through the per-channel unique index', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { user_id: 'user-reg' }, error: null })
|
||||
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint' } })
|
||||
enqueue({ data: { id: 'inbox-raced' }, error: null })
|
||||
const result = await deliverPeppolDocumentToInbox(service, { row: row(), companyId: 'company-1', document, xml: XML })
|
||||
expect(result.inboxItemId).toBe('inbox-raced')
|
||||
})
|
||||
})
|
||||
@@ -468,3 +468,74 @@ describe('helpers', () => {
|
||||
expect(describeQvaliaErrorBody(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Qvalia transport: receiving side', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const transport = createQvaliaTransport(config, { fetch: fetchMock })
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('registers a recipient with business card and both billing document types', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'registered', peppolId: '0007:5595386219' }))
|
||||
const result = await transport.registerRecipient!({
|
||||
participant: { scheme: '0007', identifier: '5595386219' },
|
||||
businessCard: { companyName: 'Arcim Technology AB', countryCode: 'SE', geographicalInformation: 'Stockholm', vatNumber: 'SE559538621901', orgNumber: '5595386219' },
|
||||
documentTypes: [
|
||||
{ processId: PEPPOL_BIS_BILLING_PROFILE_ID, documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID },
|
||||
],
|
||||
})
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toBe('https://api-qa.qvalia.com/partner/SE5560000000/account/SE5560000000/peppol/0007%3A5595386219')
|
||||
expect(init?.method).toBe('PUT')
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.businessCard).toEqual({
|
||||
companyName: 'Arcim Technology AB', countryCode: 'SE', geographicalInformation: 'Stockholm',
|
||||
VAT: 'SE559538621901', orgNr: '5595386219', suffix: '',
|
||||
})
|
||||
expect(body.docTypes).toEqual([{ profile: PEPPOL_BIS_BILLING_PROFILE_ID, document: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID }])
|
||||
expect(result).toMatchObject({ status: 'registered', providerAccountReference: 'SE5560000000' })
|
||||
})
|
||||
|
||||
it('treats an unregister of an unknown id as done and surfaces other failures', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
await expect(transport.unregisterRecipient!({ scheme: '0007', identifier: '1' })).resolves.toBeUndefined()
|
||||
expect(fetchMock.mock.calls[0][1]?.method).toBe('DELETE')
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(500, { error: 'boom' }))
|
||||
await expect(transport.unregisterRecipient!({ scheme: '0007', identifier: '1' })).rejects.toMatchObject({ kind: 'unavailable' })
|
||||
})
|
||||
|
||||
it('lists unread inbound invoices through the marking endpoint and keeps the payload', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, {
|
||||
status: 'success',
|
||||
data: [
|
||||
{ integrationId: 'in-1', Invoice: { 'cbc:ID': [{ _: '20267497' }] } },
|
||||
{ integrationId: 'in-2', Invoice: { 'cbc:ID': [{ _: '20267498' }] } },
|
||||
],
|
||||
}))
|
||||
const messages = await transport.listInboundDocuments!({ documentType: 'Invoice', limit: 10 })
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'https://api-qa.qvalia.com/partner/SE5560000000/transaction/SE5560000000/invoices/incoming/readinvoices?limit=10',
|
||||
)
|
||||
expect(messages.map((m) => m.providerDocumentId)).toEqual(['in-1', 'in-2'])
|
||||
expect(messages[0]).toMatchObject({ provider: 'qvalia', documentType: 'Invoice' })
|
||||
expect(messages[0].payload).toHaveProperty('Invoice')
|
||||
})
|
||||
|
||||
it('re-syncs with includeRead, handles 204 as empty, and reads credit notes from their own collection', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
expect(await transport.listInboundDocuments!({ documentType: 'CreditNote', includeRead: true })).toEqual([])
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/creditnotes/incoming?includeRead=true&limit=25')
|
||||
})
|
||||
|
||||
it('fetches the exact inbound XML and returns null when the provider has nothing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(XML, { status: 200, headers: { 'content-type': 'application/xml' } }))
|
||||
expect(await transport.fetchInboundDocumentXml!('in-1', 'Invoice')).toBe(XML)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toContain('/invoices/incoming?integrationId=in-1&includeRead=true&limit=1')
|
||||
expect((init?.headers as Record<string, string>).accept).toBe('application/xml')
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
expect(await transport.fetchInboundDocumentXml!('in-2', 'Invoice')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID,
|
||||
PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
deregisterCompanyFromPeppolReceiving,
|
||||
preparePeppolParticipant,
|
||||
registerCompanyForPeppolReceiving,
|
||||
} from '@/lib/invoices/peppol-registration'
|
||||
import { PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID } from '@/lib/invoices/peppol-bis-billing'
|
||||
import { PeppolTransportError, type PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const { supabase: mockService, enqueue, reset, calls } = createQueuedMockSupabase()
|
||||
const service = mockService as unknown as SupabaseClient
|
||||
|
||||
const settings = {
|
||||
org_number: '559538-6219',
|
||||
company_name: 'Arcim Technology AB',
|
||||
vat_number: 'SE559538621901',
|
||||
city: 'Stockholm',
|
||||
country: 'SE',
|
||||
}
|
||||
|
||||
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
|
||||
return {
|
||||
provider: 'qvalia',
|
||||
lookupRecipient: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
verifyWebhook: vi.fn(),
|
||||
retrieveEvidence: vi.fn(),
|
||||
registerRecipient: vi.fn().mockResolvedValue({
|
||||
status: 'registered',
|
||||
participant: { scheme: '0007', identifier: '5595386219' },
|
||||
providerAccountReference: 'SE5595386219',
|
||||
raw: {},
|
||||
}),
|
||||
unregisterRecipient: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const registeredRow = {
|
||||
id: 'reg-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
provider: 'qvalia',
|
||||
provider_account_reference: 'SE5595386219',
|
||||
participant_scheme: '0007',
|
||||
participant_identifier: '5595386219',
|
||||
status: 'registered',
|
||||
business_card: {},
|
||||
document_types: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
registered_at: '2026-08-21T16:00:00.000Z',
|
||||
deregistered_at: null,
|
||||
last_error: null,
|
||||
created_at: '2026-08-21T15:59:00.000Z',
|
||||
updated_at: '2026-08-21T16:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('preparePeppolParticipant', () => {
|
||||
it('derives 0007 + organisation number and the business card', () => {
|
||||
expect(preparePeppolParticipant(settings)).toEqual({
|
||||
ok: true,
|
||||
participant: { scheme: '0007', identifier: '5595386219' },
|
||||
businessCard: {
|
||||
companyName: 'Arcim Technology AB',
|
||||
countryCode: 'SE',
|
||||
geographicalInformation: 'Stockholm',
|
||||
vatNumber: 'SE559538621901',
|
||||
orgNumber: '5595386219',
|
||||
},
|
||||
})
|
||||
expect(preparePeppolParticipant({ ...settings, org_number: '16559538-6219' })).toMatchObject({
|
||||
ok: true,
|
||||
participant: { identifier: '5595386219' },
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses missing numbers, personnummer and missing names', () => {
|
||||
expect(preparePeppolParticipant({ ...settings, org_number: null })).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED' })
|
||||
expect(preparePeppolParticipant({ ...settings, org_number: '198001011234' })).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED' })
|
||||
expect(preparePeppolParticipant({ ...settings, org_number: '8001011234' })).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_PERSONAL_NUMBER' })
|
||||
expect(preparePeppolParticipant({ ...settings, company_name: ' ' })).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerCompanyForPeppolReceiving', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('advertises Invoice and CreditNote for BIS Billing 3', () => {
|
||||
expect(PEPPOL_RECEIVING_DOCUMENT_TYPES.map((t) => t.documentTypeId)).toEqual([
|
||||
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID,
|
||||
])
|
||||
})
|
||||
|
||||
it('writes pending, publishes through the transport, then finalizes as registered', async () => {
|
||||
const transport = makeTransport()
|
||||
enqueue({ data: [], error: null }) // existing registrations
|
||||
enqueue({ data: { id: 'reg-1' }, error: null }) // insert pending
|
||||
enqueue({ data: registeredRow, error: null }) // finalize update
|
||||
|
||||
const result = await registerCompanyForPeppolReceiving({
|
||||
service, companyId: 'company-1', userId: 'user-1', transport, settings,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, registration: registeredRow })
|
||||
expect(transport.registerRecipient).toHaveBeenCalledWith({
|
||||
participant: { scheme: '0007', identifier: '5595386219' },
|
||||
businessCard: expect.objectContaining({ companyName: 'Arcim Technology AB', orgNumber: '5595386219' }),
|
||||
documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
})
|
||||
const inserted = calls.find((c) => c.method === 'insert')
|
||||
expect(inserted?.args[0]).toMatchObject({ status: 'pending', participant_identifier: '5595386219', company_id: 'company-1' })
|
||||
const finalized = calls.filter((c) => c.method === 'update').at(-1)
|
||||
expect(finalized?.args[0]).toMatchObject({ status: 'registered', provider_account_reference: 'SE5595386219', last_error: null })
|
||||
})
|
||||
|
||||
it('records a failed registration with the provider reason and reports it', async () => {
|
||||
const transport = makeTransport({
|
||||
registerRecipient: vi.fn().mockRejectedValue(new PeppolTransportError('Qvalia answered 500', { retryable: true, detail: 'smp down' })),
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { id: 'reg-1' }, error: null })
|
||||
enqueue({ data: null, error: null }) // failure update
|
||||
|
||||
const result = await registerCompanyForPeppolReceiving({
|
||||
service, companyId: 'company-1', userId: 'user-1', transport, settings,
|
||||
})
|
||||
expect(result).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_FAILED', detail: 'smp down' })
|
||||
const failed = calls.filter((c) => c.method === 'update').at(-1)
|
||||
expect(failed?.args[0]).toMatchObject({ status: 'failed', last_error: 'Qvalia answered 500: smp down' })
|
||||
})
|
||||
|
||||
it('stops before the network on a personnummer and on a send-only transport', async () => {
|
||||
const transport = makeTransport()
|
||||
expect(await registerCompanyForPeppolReceiving({
|
||||
service, companyId: 'company-1', userId: 'user-1', transport, settings: { ...settings, org_number: '8001011234' },
|
||||
})).toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_PERSONAL_NUMBER' })
|
||||
expect(transport.registerRecipient).not.toHaveBeenCalled()
|
||||
|
||||
const sendOnly = makeTransport({ registerRecipient: undefined })
|
||||
expect(await registerCompanyForPeppolReceiving({
|
||||
service, companyId: 'company-1', userId: 'user-1', transport: sendOnly, settings,
|
||||
})).toEqual({ ok: false, code: 'PEPPOL_RECEIVING_UNSUPPORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deregisterCompanyFromPeppolReceiving', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('withdraws the live identifier and marks the row deregistered', async () => {
|
||||
const transport = makeTransport()
|
||||
enqueue({ data: [registeredRow], error: null })
|
||||
enqueue({ data: { ...registeredRow, status: 'deregistered', deregistered_at: '2026-08-21T17:00:00.000Z' }, error: null })
|
||||
const result = await deregisterCompanyFromPeppolReceiving({ service, companyId: 'company-1', transport })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(transport.unregisterRecipient).toHaveBeenCalledWith({ scheme: '0007', identifier: '5595386219' })
|
||||
})
|
||||
|
||||
it('reports not found when nothing is live', async () => {
|
||||
enqueue({ data: [{ ...registeredRow, status: 'deregistered' }], error: null })
|
||||
expect(await deregisterCompanyFromPeppolReceiving({ service, companyId: 'company-1', transport: makeTransport() }))
|
||||
.toEqual({ ok: false, code: 'PEPPOL_REGISTRATION_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Hand a routed inbound Peppol document to the supplier-invoice inbox.
|
||||
*
|
||||
* Follows the mail-hunt precedent (lib/receipt-hunt/ingest.ts): core archives
|
||||
* the underlag through uploadDocument() and inserts the inbox row directly,
|
||||
* with the extraction already filled in from the structured UBL, so no AI
|
||||
* pass runs and the reviewer sees exactly what the sender wrote.
|
||||
*
|
||||
* What is archived:
|
||||
* - the exact received XML, always, as a WORM document (upload_source
|
||||
* 'e_invoice', extractionOwner 'none'): that is the räkenskapsinformation;
|
||||
* - an embedded PDF rendering, when the sender attached one, as the document
|
||||
* the inbox shows (people read PDFs, not UBL).
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { matchSupplierId } from '@/lib/suppliers/match-supplier'
|
||||
import type { PeppolInboundDelivery } from '@/lib/invoices/peppol-inbound'
|
||||
import type { PeppolInboundDocument, PeppolInboundLine } from '@/lib/invoices/peppol-inbound-ubl'
|
||||
import type {
|
||||
ExtractedInvoiceLineItem,
|
||||
InboxChannelContext,
|
||||
InvoiceExtractionResult,
|
||||
VatBreakdownItem,
|
||||
} from '@/types'
|
||||
|
||||
function formatGiro(digits: string | null): string | null {
|
||||
if (!digits) return null
|
||||
// Bankgiro 7-8 digits: XXX-XXXX / XXXX-XXXX; plusgiro: digits with a final
|
||||
// check digit after the hyphen. Both follow the inbox's "with hyphen" rule.
|
||||
if (digits.length === 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`
|
||||
if (digits.length === 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`
|
||||
return `${digits.slice(0, -1)}-${digits.slice(-1)}`
|
||||
}
|
||||
|
||||
function lineToExtracted(line: PeppolInboundLine, sign: 1 | -1): ExtractedInvoiceLineItem {
|
||||
const quantity = line.quantity ?? 1
|
||||
const lineTotal = line.lineExtensionAmount ?? (line.priceAmount !== null ? roundOre(line.priceAmount * quantity) : 0)
|
||||
return {
|
||||
description: line.name ?? line.description ?? line.sellersItemId ?? '',
|
||||
quantity,
|
||||
unitPrice: line.priceAmount !== null ? roundOre(line.priceAmount * sign) : null,
|
||||
lineTotal: roundOre(lineTotal * sign),
|
||||
vatRate: line.vatPercent !== null ? Math.round(line.vatPercent) : null,
|
||||
accountSuggestion: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the UBL reading onto the inbox's extraction shape. Credit notes are
|
||||
* expressed as negative amounts, which is how the inbox/supplier-invoice flow
|
||||
* already represents a credit from the supplier.
|
||||
*/
|
||||
export function peppolDocumentToExtraction(document: PeppolInboundDocument): InvoiceExtractionResult {
|
||||
const sign: 1 | -1 = document.documentType === 'CreditNote' ? -1 : 1
|
||||
const bankgiro = document.paymentMeans.map((m) => m.bankgiro).find((v): v is string => !!v) ?? null
|
||||
const plusgiro = document.paymentMeans.map((m) => m.plusgiro).find((v): v is string => !!v) ?? null
|
||||
const paymentReference = document.paymentMeans.map((m) => m.paymentId).find((v): v is string => !!v) ?? null
|
||||
const supplier = document.supplier
|
||||
const addressParts = [
|
||||
supplier.street,
|
||||
supplier.additionalStreet,
|
||||
[supplier.postalZone, supplier.city].filter(Boolean).join(' ') || null,
|
||||
supplier.countryCode && supplier.countryCode.toUpperCase() !== 'SE' ? supplier.countryCode : null,
|
||||
].filter((part): part is string => !!part)
|
||||
|
||||
const vatBreakdown: VatBreakdownItem[] = document.taxSubtotals
|
||||
.filter((s) => s.percent !== null && s.taxableAmount !== null && s.taxAmount !== null)
|
||||
.map((s) => ({
|
||||
rate: Math.round(s.percent as number),
|
||||
base: roundOre((s.taxableAmount as number) * sign),
|
||||
amount: roundOre((s.taxAmount as number) * sign),
|
||||
}))
|
||||
|
||||
const subtotal = document.totals.taxExclusive ?? document.totals.lineExtension
|
||||
const vatAmount = document.totals.taxAmount
|
||||
const total = document.totals.payable ?? document.totals.taxInclusive
|
||||
|
||||
return {
|
||||
documentKind: 'supplier_invoice',
|
||||
legibility: 'good',
|
||||
supplier: {
|
||||
name: supplier.name,
|
||||
orgNumber: supplier.orgNumber,
|
||||
vatNumber: supplier.vatNumber,
|
||||
address: addressParts.length ? addressParts.join(', ') : null,
|
||||
bankgiro: formatGiro(bankgiro),
|
||||
plusgiro: formatGiro(plusgiro),
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: document.documentId || null,
|
||||
invoiceDate: document.issueDate,
|
||||
dueDate: document.dueDate,
|
||||
paymentReference,
|
||||
currency: document.currency ?? 'SEK',
|
||||
},
|
||||
lineItems: document.lines.map((line) => lineToExtracted(line, sign)),
|
||||
totals: {
|
||||
subtotal: subtotal !== null ? roundOre(subtotal * sign) : null,
|
||||
vatAmount: vatAmount !== null ? roundOre(vatAmount * sign) : null,
|
||||
total: total !== null ? roundOre(total * sign) : null,
|
||||
roundingAmount: document.totals.payableRounding !== null && document.totals.payableRounding !== 0
|
||||
? roundOre(document.totals.payableRounding * sign)
|
||||
: null,
|
||||
},
|
||||
vatBreakdown,
|
||||
// Structured e-invoice: the numbers are the sender's own, not a model's reading.
|
||||
confidence: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function safeFilename(base: string, extension: string): string {
|
||||
const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'peppol'
|
||||
return `${cleaned}.${extension}`
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/** Who the inbox row belongs to: the member who registered, else the company's first owner. */
|
||||
export async function resolvePeppolInboxOwner(args: {
|
||||
service: SupabaseClient
|
||||
companyId: string
|
||||
provider: string
|
||||
}): Promise<string | null> {
|
||||
const { data: registration } = await args.service
|
||||
.from('peppol_registrations')
|
||||
.select('user_id')
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('provider', args.provider)
|
||||
.eq('status', 'registered')
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
const registrant = (registration as { user_id: string | null } | null)?.user_id
|
||||
if (registrant) return registrant
|
||||
|
||||
const { data: owner } = await args.service
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('role', 'owner')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
return (owner as { user_id: string } | null)?.user_id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The deliverer used by the inbound sync. Idempotent on the provider document
|
||||
* id via the inbox's per-channel unique index: a replay returns the existing
|
||||
* inbox item instead of a second one.
|
||||
*/
|
||||
export async function deliverPeppolDocumentToInbox(
|
||||
service: SupabaseClient,
|
||||
delivery: PeppolInboundDelivery,
|
||||
): Promise<{ inboxItemId: string | null; xmlDocumentId: string | null }> {
|
||||
const { row, companyId, document, xml } = delivery
|
||||
|
||||
const { data: existingItem } = await service
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, channel_context')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source', 'peppol')
|
||||
.eq('channel_context->>peppol_document_id', row.provider_document_id)
|
||||
.maybeSingle()
|
||||
if (existingItem) {
|
||||
const context = (existingItem as { channel_context?: InboxChannelContext | null }).channel_context
|
||||
return {
|
||||
inboxItemId: (existingItem as { id: string }).id,
|
||||
xmlDocumentId: context?.peppol_xml_document_id ?? row.xml_document_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await resolvePeppolInboxOwner({ service, companyId, provider: row.provider })
|
||||
if (!userId) throw new Error('No owner member found for the receiving company')
|
||||
|
||||
const baseName = `peppol-${document.documentType === 'CreditNote' ? 'kreditnota' : 'faktura'}-${document.documentId || row.provider_document_id}`
|
||||
|
||||
// 1. The exact received XML, always archived.
|
||||
let xmlDocumentId: string | null = row.xml_document_id
|
||||
if (!xmlDocumentId && xml) {
|
||||
const archived = await uploadDocument(
|
||||
service,
|
||||
userId,
|
||||
companyId,
|
||||
{ name: safeFilename(baseName, 'xml'), buffer: toArrayBuffer(Buffer.from(xml, 'utf8')), type: 'application/xml' },
|
||||
{ upload_source: 'e_invoice', dedupeByContent: true, extractionOwner: 'none' },
|
||||
)
|
||||
xmlDocumentId = archived.id
|
||||
}
|
||||
|
||||
// 2. An embedded PDF rendering, when the sender attached one.
|
||||
let pdfDocumentId: string | null = null
|
||||
const pdf = document.attachments.find((a) => a.base64 && (a.mimeType ?? '').toLowerCase() === 'application/pdf')
|
||||
if (pdf?.base64) {
|
||||
try {
|
||||
const bytes = Buffer.from(pdf.base64, 'base64')
|
||||
if (bytes.length > 0) {
|
||||
const rendered = await uploadDocument(
|
||||
service,
|
||||
userId,
|
||||
companyId,
|
||||
{ name: pdf.filename || safeFilename(baseName, 'pdf'), buffer: toArrayBuffer(bytes), type: 'application/pdf' },
|
||||
{ upload_source: 'e_invoice', dedupeByContent: true, extractionOwner: 'none' },
|
||||
)
|
||||
pdfDocumentId = rendered.id
|
||||
}
|
||||
} catch {
|
||||
// A broken attachment must not keep the invoice out of the inbox; the
|
||||
// XML archive and the structured data stand on their own.
|
||||
pdfDocumentId = null
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = peppolDocumentToExtraction(document)
|
||||
const matchedSupplierId = await matchSupplierId(service, companyId, {
|
||||
orgNumber: extracted.supplier.orgNumber,
|
||||
vatNumber: extracted.supplier.vatNumber,
|
||||
name: extracted.supplier.name,
|
||||
})
|
||||
|
||||
const channelContext: InboxChannelContext = {
|
||||
channel: 'peppol',
|
||||
peppol_provider: row.provider,
|
||||
peppol_document_id: row.provider_document_id,
|
||||
peppol_document_type: document.documentType,
|
||||
peppol_sender_endpoint: document.supplier.endpoint
|
||||
? `${document.supplier.endpoint.scheme}:${document.supplier.endpoint.identifier}`
|
||||
: null,
|
||||
peppol_xml_document_id: xmlDocumentId,
|
||||
}
|
||||
|
||||
const { data: item, error } = await service
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
document_id: pdfDocumentId ?? xmlDocumentId,
|
||||
source: 'peppol',
|
||||
status: 'received',
|
||||
extracted_data: extracted,
|
||||
extraction_skipped: false,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
email_from: document.supplier.name,
|
||||
email_received_at: row.received_at,
|
||||
channel_context: channelContext,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// The per-channel unique index did its job under a concurrent run.
|
||||
if (error.code === '23505') {
|
||||
const { data: raced } = await service
|
||||
.from('invoice_inbox_items')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source', 'peppol')
|
||||
.eq('channel_context->>peppol_document_id', row.provider_document_id)
|
||||
.maybeSingle()
|
||||
if (raced) return { inboxItemId: (raced as { id: string }).id, xmlDocumentId }
|
||||
}
|
||||
throw new Error(`Failed to create inbox item for inbound Peppol document: ${error.message}`)
|
||||
}
|
||||
return { inboxItemId: (item as { id: string }).id, xmlDocumentId }
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Peppol receiving: register a company's participant identifier at the
|
||||
* contracted Access Point so other parties can send e-invoices to it.
|
||||
*
|
||||
* Provider-neutral: the transport does the SMP work, this module keeps the
|
||||
* company-side record (`peppol_registrations`) truthful about it.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
|
||||
PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
} from '@/lib/invoices/peppol-bis-billing'
|
||||
import {
|
||||
isPeppolTransportError,
|
||||
type PeppolBusinessCard,
|
||||
type PeppolDocumentTypeRegistration,
|
||||
type PeppolParticipant,
|
||||
type PeppolTransport,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export const PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID =
|
||||
'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
|
||||
|
||||
/** What a receiving company advertises: BIS Billing 3 invoices and credit notes. */
|
||||
export const PEPPOL_RECEIVING_DOCUMENT_TYPES: PeppolDocumentTypeRegistration[] = [
|
||||
{ processId: PEPPOL_BIS_BILLING_PROFILE_ID, documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID },
|
||||
{ processId: PEPPOL_BIS_BILLING_PROFILE_ID, documentTypeId: PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID },
|
||||
]
|
||||
|
||||
export type PeppolRegistrationStatus = 'pending' | 'registered' | 'failed' | 'deregistered'
|
||||
|
||||
export interface PeppolRegistrationRow {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string | null
|
||||
provider: string
|
||||
provider_account_reference: string | null
|
||||
participant_scheme: string
|
||||
participant_identifier: string
|
||||
status: PeppolRegistrationStatus
|
||||
business_card: Record<string, unknown>
|
||||
document_types: unknown[]
|
||||
registered_at: string | null
|
||||
deregistered_at: string | null
|
||||
last_error: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type PeppolParticipantPreparation =
|
||||
| { ok: true; participant: PeppolParticipant; businessCard: PeppolBusinessCard }
|
||||
| {
|
||||
ok: false
|
||||
code:
|
||||
| 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED'
|
||||
| 'PEPPOL_REGISTRATION_PERSONAL_NUMBER'
|
||||
| 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED'
|
||||
}
|
||||
|
||||
type ParticipantSettings = Pick<
|
||||
CompanySettings,
|
||||
'org_number' | 'company_name' | 'vat_number' | 'city' | 'country'
|
||||
>
|
||||
|
||||
/**
|
||||
* Derive the participant (scheme 0007 + organisation number) and the Peppol
|
||||
* Directory business card from the company settings. Personnummer-based
|
||||
* identifiers are refused: publishing one would put personal identity data in
|
||||
* a public directory; they need a separately configured 0088 GLN.
|
||||
*/
|
||||
export function preparePeppolParticipant(settings: ParticipantSettings): PeppolParticipantPreparation {
|
||||
const digits = (settings.org_number ?? '').replace(/\D/g, '')
|
||||
const orgNumber = digits.length === 12 && digits.startsWith('16') ? digits.slice(2) : digits
|
||||
if (orgNumber.length !== 10) return { ok: false, code: 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED' }
|
||||
// Same rule as the BIS Billing generator: an organisation number has its
|
||||
// third digit >= 2; a personnummer has a month (01-12) there.
|
||||
if (Number(orgNumber[2]) < 2) return { ok: false, code: 'PEPPOL_REGISTRATION_PERSONAL_NUMBER' }
|
||||
const companyName = settings.company_name?.trim()
|
||||
if (!companyName) return { ok: false, code: 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED' }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
participant: { scheme: '0007', identifier: orgNumber },
|
||||
businessCard: {
|
||||
companyName,
|
||||
countryCode: (settings.country || 'SE').toUpperCase().slice(0, 2),
|
||||
geographicalInformation: settings.city?.trim() || null,
|
||||
vatNumber: settings.vat_number?.replace(/\s/g, '') || null,
|
||||
orgNumber,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const LIVE_STATUSES: PeppolRegistrationStatus[] = ['pending', 'registered']
|
||||
|
||||
/** The live registration for a company at a provider, else the most recent history row. */
|
||||
export async function getPeppolRegistration(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
provider: string
|
||||
}): Promise<PeppolRegistrationRow | null> {
|
||||
const { data, error } = await args.supabase
|
||||
.from('peppol_registrations')
|
||||
.select('*')
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('provider', args.provider)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10)
|
||||
if (error) throw new Error(`Failed to read Peppol registration: ${error.message}`)
|
||||
const rows = (data ?? []) as PeppolRegistrationRow[]
|
||||
return rows.find((row) => LIVE_STATUSES.includes(row.status)) ?? rows[0] ?? null
|
||||
}
|
||||
|
||||
export type RegisterPeppolResult =
|
||||
| { ok: true; registration: PeppolRegistrationRow }
|
||||
| {
|
||||
ok: false
|
||||
code:
|
||||
| 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED'
|
||||
| 'PEPPOL_REGISTRATION_PERSONAL_NUMBER'
|
||||
| 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED'
|
||||
| 'PEPPOL_RECEIVING_UNSUPPORTED'
|
||||
}
|
||||
| { ok: false; code: 'PEPPOL_REGISTRATION_FAILED'; detail: string | null }
|
||||
|
||||
/**
|
||||
* Publish the company's identifier through the transport and record the
|
||||
* outcome. The row is written as `pending` before the network call and
|
||||
* finalized after it, so a crash mid-way leaves a visible pending row rather
|
||||
* than a silent gap.
|
||||
*/
|
||||
export async function registerCompanyForPeppolReceiving(args: {
|
||||
service: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
transport: PeppolTransport
|
||||
settings: ParticipantSettings
|
||||
}): Promise<RegisterPeppolResult> {
|
||||
const { service, companyId, userId, transport } = args
|
||||
if (!transport.registerRecipient) return { ok: false, code: 'PEPPOL_RECEIVING_UNSUPPORTED' }
|
||||
const prepared = preparePeppolParticipant(args.settings)
|
||||
if (!prepared.ok) return { ok: false, code: prepared.code }
|
||||
|
||||
const existing = await getPeppolRegistration({ supabase: service, companyId, provider: transport.provider })
|
||||
const live = existing && LIVE_STATUSES.includes(existing.status) ? existing : null
|
||||
|
||||
let rowId: string
|
||||
if (live) {
|
||||
rowId = live.id
|
||||
} else {
|
||||
const { data, error } = await service
|
||||
.from('peppol_registrations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
provider: transport.provider,
|
||||
participant_scheme: prepared.participant.scheme,
|
||||
participant_identifier: prepared.participant.identifier,
|
||||
status: 'pending',
|
||||
business_card: prepared.businessCard,
|
||||
document_types: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (error || !data) throw new Error(`Failed to create Peppol registration: ${error?.message ?? 'no row'}`)
|
||||
rowId = (data as { id: string }).id
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transport.registerRecipient({
|
||||
participant: prepared.participant,
|
||||
businessCard: prepared.businessCard,
|
||||
documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
})
|
||||
const { data, error } = await service
|
||||
.from('peppol_registrations')
|
||||
.update({
|
||||
status: 'registered',
|
||||
registered_at: new Date().toISOString(),
|
||||
deregistered_at: null,
|
||||
provider_account_reference: result.providerAccountReference,
|
||||
participant_scheme: prepared.participant.scheme,
|
||||
participant_identifier: prepared.participant.identifier,
|
||||
business_card: prepared.businessCard,
|
||||
document_types: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
last_error: null,
|
||||
})
|
||||
.eq('id', rowId)
|
||||
.select('*')
|
||||
.single()
|
||||
if (error || !data) throw new Error(`Failed to finalize Peppol registration: ${error?.message ?? 'no row'}`)
|
||||
return { ok: true, registration: data as PeppolRegistrationRow }
|
||||
} catch (err) {
|
||||
const detail = isPeppolTransportError(err)
|
||||
? [err.message, err.detail].filter(Boolean).join(': ').slice(0, 500)
|
||||
: err instanceof Error ? err.message.slice(0, 500) : 'unknown error'
|
||||
await service
|
||||
.from('peppol_registrations')
|
||||
.update({ status: 'failed', last_error: detail })
|
||||
.eq('id', rowId)
|
||||
return { ok: false, code: 'PEPPOL_REGISTRATION_FAILED', detail: isPeppolTransportError(err) ? err.detail : null }
|
||||
}
|
||||
}
|
||||
|
||||
export type DeregisterPeppolResult =
|
||||
| { ok: true; registration: PeppolRegistrationRow }
|
||||
| { ok: false; code: 'PEPPOL_RECEIVING_UNSUPPORTED' | 'PEPPOL_REGISTRATION_NOT_FOUND' }
|
||||
| { ok: false; code: 'PEPPOL_REGISTRATION_FAILED'; detail: string | null }
|
||||
|
||||
export async function deregisterCompanyFromPeppolReceiving(args: {
|
||||
service: SupabaseClient
|
||||
companyId: string
|
||||
transport: PeppolTransport
|
||||
}): Promise<DeregisterPeppolResult> {
|
||||
const { service, companyId, transport } = args
|
||||
if (!transport.unregisterRecipient) return { ok: false, code: 'PEPPOL_RECEIVING_UNSUPPORTED' }
|
||||
const existing = await getPeppolRegistration({ supabase: service, companyId, provider: transport.provider })
|
||||
if (!existing || !LIVE_STATUSES.includes(existing.status)) {
|
||||
return { ok: false, code: 'PEPPOL_REGISTRATION_NOT_FOUND' }
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.unregisterRecipient({
|
||||
scheme: existing.participant_scheme,
|
||||
identifier: existing.participant_identifier,
|
||||
})
|
||||
} catch (err) {
|
||||
const detail = isPeppolTransportError(err) ? err.detail : null
|
||||
await service
|
||||
.from('peppol_registrations')
|
||||
.update({ last_error: err instanceof Error ? err.message.slice(0, 500) : 'unknown error' })
|
||||
.eq('id', existing.id)
|
||||
return { ok: false, code: 'PEPPOL_REGISTRATION_FAILED', detail }
|
||||
}
|
||||
|
||||
const { data, error } = await service
|
||||
.from('peppol_registrations')
|
||||
.update({ status: 'deregistered', deregistered_at: new Date().toISOString(), last_error: null })
|
||||
.eq('id', existing.id)
|
||||
.select('*')
|
||||
.single()
|
||||
if (error || !data) throw new Error(`Failed to record Peppol deregistration: ${error?.message ?? 'no row'}`)
|
||||
return { ok: true, registration: data as PeppolRegistrationRow }
|
||||
}
|
||||
@@ -97,6 +97,56 @@ export interface PeppolWebhookRequest {
|
||||
rawBody: Uint8Array
|
||||
}
|
||||
|
||||
/** What the SMP publishes about a receiving participant (Peppol Directory business card). */
|
||||
export interface PeppolBusinessCard {
|
||||
companyName: string
|
||||
countryCode: string
|
||||
geographicalInformation?: string | null
|
||||
vatNumber?: string | null
|
||||
orgNumber?: string | null
|
||||
}
|
||||
|
||||
export interface PeppolDocumentTypeRegistration {
|
||||
/** Process identifier, e.g. urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 */
|
||||
processId: string
|
||||
/** Document type identifier, e.g. the BIS Billing 3 Invoice id. */
|
||||
documentTypeId: string
|
||||
}
|
||||
|
||||
export interface PeppolRecipientRegistrationInput {
|
||||
participant: PeppolParticipant
|
||||
businessCard: PeppolBusinessCard
|
||||
documentTypes: PeppolDocumentTypeRegistration[]
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface PeppolRecipientRegistration {
|
||||
status: 'registered' | 'updated'
|
||||
participant: PeppolParticipant
|
||||
/** Provider account the identifier is attached to (Qvalia accountRegNo). */
|
||||
providerAccountReference: string | null
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type PeppolInboundDocumentType = 'Invoice' | 'CreditNote'
|
||||
|
||||
/** One inbound document as the provider hands it over, before Accounted reads it. */
|
||||
export interface PeppolInboundMessage {
|
||||
provider: string
|
||||
providerDocumentId: string
|
||||
documentType: PeppolInboundDocumentType
|
||||
/** UBL-JSON payload (the provider's rendering of the received XML). */
|
||||
payload: Record<string, unknown>
|
||||
receivedAt: string | null
|
||||
}
|
||||
|
||||
export interface PeppolInboundListOptions {
|
||||
documentType: PeppolInboundDocumentType
|
||||
limit?: number
|
||||
/** Include documents already handed over once (re-sync); default only unread. */
|
||||
includeRead?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral failure raised by an adapter. `retryable` separates an
|
||||
* operational problem (network, rate limit, credentials) from a verdict on the
|
||||
@@ -125,6 +175,18 @@ export interface PeppolTransport {
|
||||
submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt>
|
||||
verifyWebhook(request: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]>
|
||||
retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]>
|
||||
/**
|
||||
* Receiving side. Optional: a send-only provider leaves these undefined and
|
||||
* the product keeps receiving switched off for it.
|
||||
*/
|
||||
registerRecipient?(input: PeppolRecipientRegistrationInput): Promise<PeppolRecipientRegistration>
|
||||
unregisterRecipient?(participant: PeppolParticipant): Promise<void>
|
||||
listInboundDocuments?(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]>
|
||||
/** The exact received document, for the archive (räkenskapsinformation). */
|
||||
fetchInboundDocumentXml?(
|
||||
providerDocumentId: string,
|
||||
documentType: PeppolInboundDocumentType,
|
||||
): Promise<string | null>
|
||||
}
|
||||
|
||||
const transports = new Map<string, PeppolTransport>()
|
||||
|
||||
@@ -36,9 +36,14 @@ import {
|
||||
PeppolTransportError,
|
||||
type PeppolDeliveryEvidence,
|
||||
type PeppolDeliveryStatus,
|
||||
type PeppolInboundDocumentType,
|
||||
type PeppolInboundListOptions,
|
||||
type PeppolInboundMessage,
|
||||
type PeppolParticipant,
|
||||
type PeppolRecipientCapability,
|
||||
type PeppolRecipientLookup,
|
||||
type PeppolRecipientRegistration,
|
||||
type PeppolRecipientRegistrationInput,
|
||||
type PeppolSubmission,
|
||||
type PeppolSubmissionReceipt,
|
||||
type PeppolTransport,
|
||||
@@ -413,7 +418,7 @@ export function createQvaliaTransport(
|
||||
const transactionBase = `${config.baseUrl}/partner/${partner}/transaction/${account}`
|
||||
|
||||
async function request(
|
||||
method: 'GET' | 'POST',
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
url: string,
|
||||
init: { headers?: Record<string, string>; body?: string } = {},
|
||||
): Promise<Response> {
|
||||
@@ -636,11 +641,106 @@ export function createQvaliaTransport(
|
||||
}]
|
||||
}
|
||||
|
||||
// ---- receiving side -------------------------------------------------
|
||||
|
||||
async function registerRecipient(input: PeppolRecipientRegistrationInput): Promise<PeppolRecipientRegistration> {
|
||||
const peppolId = `${input.participant.scheme}:${input.participant.identifier}`
|
||||
const url = `${config.baseUrl}/partner/${partner}/account/${account}/peppol/${encodePathSegment(peppolId)}`
|
||||
const body = {
|
||||
description: input.description ?? `Accounted: ${input.businessCard.companyName}`,
|
||||
businessCard: {
|
||||
companyName: input.businessCard.companyName,
|
||||
countryCode: input.businessCard.countryCode,
|
||||
geographicalInformation: input.businessCard.geographicalInformation ?? '',
|
||||
VAT: input.businessCard.vatNumber ?? '',
|
||||
orgNr: input.businessCard.orgNumber ?? '',
|
||||
suffix: '',
|
||||
},
|
||||
docTypes: input.documentTypes.map((type) => ({ profile: type.processId, document: type.documentTypeId })),
|
||||
}
|
||||
const response = await request('PUT', url, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const { text, json } = await readBody(response)
|
||||
if (!response.ok) throw classifyHttpFailure(response.status, json, text)
|
||||
const record = asRecord(json)
|
||||
const status = asString(record?.status)
|
||||
return {
|
||||
status: status === 'updated' ? 'updated' : 'registered',
|
||||
participant: input.participant,
|
||||
providerAccountReference: config.accountRegNo,
|
||||
raw: record ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterRecipient(participant: PeppolParticipant): Promise<void> {
|
||||
const peppolId = `${participant.scheme}:${participant.identifier}`
|
||||
const url = `${config.baseUrl}/partner/${partner}/account/${account}/peppol/${encodePathSegment(peppolId)}`
|
||||
const response = await request('DELETE', url)
|
||||
if (response.status === 404 || response.status === 204) return
|
||||
const { text, json } = await readBody(response)
|
||||
if (!response.ok) throw classifyHttpFailure(response.status, json, text)
|
||||
}
|
||||
|
||||
function inboundPath(documentType: PeppolInboundDocumentType): { collection: string; read: string } {
|
||||
return documentType === 'CreditNote'
|
||||
? { collection: 'creditnotes', read: 'readcreditnotes' }
|
||||
: { collection: 'invoices', read: 'readinvoices' }
|
||||
}
|
||||
|
||||
async function listInboundDocuments(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]> {
|
||||
const { collection, read } = inboundPath(options.documentType)
|
||||
const limit = Math.min(Math.max(options.limit ?? 25, 1), 100)
|
||||
// The "read" endpoint returns only documents not yet handed over and marks
|
||||
// them read; the plain endpoint with includeRead=true re-syncs everything.
|
||||
const url = options.includeRead
|
||||
? `${transactionBase}/${collection}/incoming?includeRead=true&limit=${limit}`
|
||||
: `${transactionBase}/${collection}/incoming/${read}?limit=${limit}`
|
||||
const response = await request('GET', url)
|
||||
if (response.status === 204) return []
|
||||
const { text, json } = await readBody(response)
|
||||
if (!response.ok) throw classifyHttpFailure(response.status, json, text)
|
||||
const data = asRecord(json)?.data ?? json
|
||||
const items = Array.isArray(data) ? data : data ? [data] : []
|
||||
const messages: PeppolInboundMessage[] = []
|
||||
for (const item of items) {
|
||||
const record = asRecord(item)
|
||||
const integrationId = extractIntegrationId(record)
|
||||
if (!record || !integrationId) continue
|
||||
messages.push({
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerDocumentId: integrationId,
|
||||
documentType: options.documentType,
|
||||
payload: record,
|
||||
receivedAt: asString(record.createdAt) ?? asString(record.created_at) ?? null,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
async function fetchInboundDocumentXml(
|
||||
providerDocumentId: string,
|
||||
documentType: PeppolInboundDocumentType,
|
||||
): Promise<string | null> {
|
||||
const { collection } = inboundPath(documentType)
|
||||
const url = `${transactionBase}/${collection}/incoming?integrationId=${encodeURIComponent(providerDocumentId)}&includeRead=true&limit=1`
|
||||
const response = await request('GET', url, { headers: { accept: 'application/xml' } })
|
||||
if (response.status === 204 || response.status === 404) return null
|
||||
const text = await response.text()
|
||||
if (!response.ok) throw classifyHttpFailure(response.status, null, text)
|
||||
return text.trim().startsWith('<') ? text : null
|
||||
}
|
||||
|
||||
return {
|
||||
provider: QVALIA_PROVIDER,
|
||||
lookupRecipient,
|
||||
submit,
|
||||
verifyWebhook,
|
||||
retrieveEvidence,
|
||||
registerRecipient,
|
||||
unregisterRecipient,
|
||||
listInboundDocuments,
|
||||
fetchInboundDocumentXml,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +867,14 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
file: 'peppol_delivery_evidence.json',
|
||||
orderBy: 'created_at',
|
||||
},
|
||||
// Receiving side: which identifiers the company published, and every
|
||||
// inbound e-invoice with the exact received XML (the underlag itself).
|
||||
{ name: 'peppol_registrations', file: 'peppol_registrations.json', orderBy: 'created_at' },
|
||||
{
|
||||
name: 'peppol_inbound_documents',
|
||||
file: 'peppol_inbound_documents.json',
|
||||
orderBy: 'received_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