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 }
}
+19
View File
@@ -16,6 +16,9 @@
* Lists the Peppol identifiers registered on the partner account.
* npx tsx scripts/peppol/qvalia-probe.ts outgoing
* Lists the three latest outgoing invoice statuses (read-only).
* npx tsx scripts/peppol/qvalia-probe.ts incoming [integrationId]
* Lists the latest incoming invoice statuses, or prints one incoming
* invoice as XML (read-only: uses the non-marking endpoint).
* npx tsx scripts/peppol/qvalia-probe.ts send path/to/invoice.xml
* Submits a BIS Billing 3 XML through the adapter. Sandbox only: the
* script refuses a production base URL.
@@ -110,6 +113,22 @@ async function main(): Promise<void> {
)
return
}
case 'incoming': {
if (argument) {
const response = await fetch(
`${config!.baseUrl}/partner/${partner}/transaction/${account}/invoices/incoming?integrationId=${encodeURIComponent(argument)}&includeRead=true&limit=1`,
{ headers: { Authorization: headerFor(config!.authScheme), accept: 'application/xml' } },
)
console.log(`HTTP ${response.status}`)
console.log((await response.text()).slice(0, 6000))
return
}
await show(
'GET /partner/{p}/transaction/{a}/invoices/incoming/status',
await rawGet(`/partner/${partner}/transaction/${account}/invoices/incoming/status?includeRead=true&limit=5`),
)
return
}
case 'webhook': {
await show('GET /partner/{p}/webhook/configure', await rawGet(`/partner/${partner}/webhook/configure`))
return