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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
72c81c21e7
commit
316189675a
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user