fix(peppol): read Qvalia's prefixed UBL-JSON keys, add incoming probe (#1786)

Qvalia's UBL-JSON keeps namespace prefixes (cac:AccountingSupplierParty,
cbc:EndpointID) with attributes under `$` (verified live 2026-08-21 on the
inbound test invoice Joanna sent to 0007:5595386219), not the unprefixed
OASIS form the 409-recovery extractor assumed. Accept both. The probe gains
`incoming [integrationId]` to list inbound statuses or print one inbound
invoice as XML without marking it read.

Refs #546


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:
Jakob Wennberg
2026-08-21 16:09:40 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 72c81c21e7
commit 316189675a
3 changed files with 54 additions and 9 deletions
@@ -271,8 +271,10 @@ describe('Qvalia transport: submit', () => {
data: [{
integrationId: 'int-dup',
Invoice: {
ID: [{ _: 'F-2026-42' }],
AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '556016-0680', schemeID: '0007' }] }] }],
'cbc:ID': [{ _: 'F-2026-42' }],
'cac:AccountingSupplierParty': [{
'cac:Party': [{ 'cbc:EndpointID': [{ _: '556016-0680', $: { schemeID: '0007' } }] }],
}],
},
}],
}))
@@ -446,6 +448,16 @@ describe('helpers', () => {
expect(extractUblJsonSupplierEndpoint({
Invoice: { AccountingSupplierParty: [{ Party: [{ EndpointID: [{ _: '1', schemeID: '0007' }] }] }] },
})).toEqual({ scheme: '0007', identifier: '1' })
// Qvalia's live shape: prefixed keys, attributes under `$`.
expect(extractUblJsonSupplierEndpoint({
Invoice: {
$: { xmlns: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2' },
'cac:AccountingSupplierParty': [{
'cac:Party': [{ 'cbc:EndpointID': [{ _: '5567321707', $: { schemeID: '0007' } }] }],
}],
},
integrationId: 'a5845a11-4e5a-4700-bca3-e670a6cd8a79',
})).toEqual({ scheme: '0007', identifier: '5567321707' })
expect(extractUblJsonSupplierEndpoint({ Invoice: {} })).toBeNull()
})
+21 -7
View File
@@ -242,18 +242,32 @@ export function extractUblDocumentId(xml: string): string | null {
}
/**
* Dig the seller endpoint identifier out of a UBL-JSON invoice as Qvalia
* returns it (OASIS UBL 2.1 JSON: arrays everywhere, text under `_`).
* UBL-JSON as Qvalia returns it (verified live 2026-08-21): xml2js style, so
* element keys keep their namespace prefix (`cac:AccountingSupplierParty`,
* `cbc:EndpointID`), every element is an array, text sits under `_` and
* attributes under `$`. The OASIS UBL-JSON form (unprefixed keys, attributes
* beside `_`) is accepted too.
*/
function ublChild(record: Record<string, unknown> | null, name: string): unknown {
if (!record) return undefined
return record[name] ?? record[`cac:${name}`] ?? record[`cbc:${name}`]
}
function ublAttribute(element: Record<string, unknown> | null, name: string): string | null {
if (!element) return null
return asString(element[name]) ?? asString(asRecord(element.$)?.[name])
}
/** Dig the seller endpoint identifier out of a UBL-JSON invoice message. */
export function extractUblJsonSupplierEndpoint(message: unknown): PeppolParticipant | null {
const record = asRecord(message)
if (!record) return null
const invoice = asRecord(record.Invoice) ?? record
const supplierParty = firstRecord(invoice.AccountingSupplierParty)
const party = firstRecord(supplierParty?.Party)
const endpoint = firstRecord(party?.EndpointID)
const invoice = asRecord(ublChild(record, 'Invoice')) ?? record
const supplierParty = firstRecord(ublChild(invoice, 'AccountingSupplierParty'))
const party = firstRecord(ublChild(supplierParty, 'Party'))
const endpoint = firstRecord(ublChild(party, 'EndpointID'))
const identifier = asString(endpoint?._)
const scheme = asString(endpoint?.schemeID)
const scheme = ublAttribute(endpoint, 'schemeID')
if (!identifier || !scheme) return null
return { scheme, identifier }
}