f93152c397
* 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>
158 lines
7.0 KiB
TypeScript
158 lines
7.0 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getPool, runAsServiceRole, withUserContext } from './setup'
|
|
import { insertAuthUser, insertCompanyMember, seedCompany } from './fixtures'
|
|
|
|
const XML = '<Invoice><cbc:ID>20267497</cbc:ID></Invoice>'
|
|
const XML_SHA = createHash('sha256').update(XML).digest('hex')
|
|
|
|
async function insertRegistration(companyId: string, userId: string, identifier: string, status = 'registered') {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.peppol_registrations
|
|
(id, company_id, user_id, provider, provider_account_reference,
|
|
participant_scheme, participant_identifier, status, registered_at, deregistered_at)
|
|
VALUES ($1, $2, $3, 'qvalia', 'SE5595386219', '0007', $4, $5,
|
|
CASE WHEN $5 = 'registered' THEN now() ELSE NULL END,
|
|
CASE WHEN $5 = 'deregistered' THEN now() ELSE NULL END)`,
|
|
[id, companyId, userId, identifier, status],
|
|
)
|
|
return id
|
|
}
|
|
|
|
async function insertInbound(companyId: string | null, providerDocumentId = randomUUID()) {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.peppol_inbound_documents
|
|
(id, provider, provider_document_id, document_type, document_id, issue_date,
|
|
currency, payable_amount, sender_scheme, sender_identifier, sender_name,
|
|
recipient_scheme, recipient_identifier, company_id, status, xml_payload, xml_sha256)
|
|
VALUES ($1, 'qvalia', $2, 'Invoice', '20267497', '2026-08-21',
|
|
'SEK', 112.00, '0007', '5567321707', 'Qvalia AB',
|
|
'0007', '5595386219', $3, $4, $5, $6)`,
|
|
[id, providerDocumentId, companyId, companyId ? 'routed' : 'received', XML, XML_SHA],
|
|
)
|
|
return id
|
|
}
|
|
|
|
describe('peppol_registrations', () => {
|
|
it('allows one live registration per participant and per company, history rows aside', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
await insertRegistration(a.companyId, a.userId, '5595386219')
|
|
|
|
await expect(insertRegistration(b.companyId, b.userId, '5595386219'))
|
|
.rejects.toThrow(/peppol_registrations_live_participant/)
|
|
await expect(insertRegistration(a.companyId, a.userId, '5560160680'))
|
|
.rejects.toThrow(/peppol_registrations_live_company/)
|
|
|
|
// A deregistered history row does not block a new live one.
|
|
await insertRegistration(b.companyId, b.userId, '5567321707', 'deregistered')
|
|
await expect(insertRegistration(b.companyId, b.userId, '5567321707')).resolves.toBeTruthy()
|
|
})
|
|
|
|
it('is readable by members of the company only and not writable by authenticated users', async () => {
|
|
const own = await seedCompany()
|
|
const other = await seedCompany()
|
|
await insertRegistration(own.companyId, own.userId, '5590000001')
|
|
await insertRegistration(other.companyId, other.userId, '5590000002')
|
|
|
|
const visible = await withUserContext(own.userId, async (client) => {
|
|
const { rows } = await client.query(
|
|
`SELECT participant_identifier FROM public.peppol_registrations ORDER BY participant_identifier`,
|
|
)
|
|
return rows.map((row) => row.participant_identifier as string)
|
|
})
|
|
expect(visible).toEqual(['5590000001'])
|
|
|
|
await expect(withUserContext(own.userId, (client) =>
|
|
client.query(
|
|
`INSERT INTO public.peppol_registrations (company_id, provider, participant_scheme, participant_identifier)
|
|
VALUES ($1, 'qvalia', '0007', '5590000003')`,
|
|
[own.companyId],
|
|
),
|
|
)).rejects.toThrow(/permission denied|row-level security/)
|
|
})
|
|
})
|
|
|
|
describe('peppol_inbound_documents', () => {
|
|
it('keeps the received document immutable and undeletable while processing state may change', async () => {
|
|
const seeded = await seedCompany()
|
|
const id = await insertInbound(seeded.companyId)
|
|
|
|
await expect(getPool().query(
|
|
`UPDATE public.peppol_inbound_documents SET xml_payload = '<Invoice/>' WHERE id = $1`, [id],
|
|
)).rejects.toThrow(/payload is immutable/)
|
|
await expect(getPool().query(
|
|
`UPDATE public.peppol_inbound_documents SET provider_document_id = 'other' WHERE id = $1`, [id],
|
|
)).rejects.toThrow(/identity is immutable/)
|
|
await expect(getPool().query(
|
|
`DELETE FROM public.peppol_inbound_documents WHERE id = $1`, [id],
|
|
)).rejects.toThrow(/cannot be deleted/)
|
|
|
|
await expect(getPool().query(
|
|
`UPDATE public.peppol_inbound_documents
|
|
SET status = 'converted', processed_at = now(), summary = '{"ok":true}'::jsonb
|
|
WHERE id = $1`, [id],
|
|
)).resolves.toBeTruthy()
|
|
})
|
|
|
|
it('routes once: company_id may be set from null but never changed afterwards', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
const id = await insertInbound(null)
|
|
|
|
await getPool().query(
|
|
`UPDATE public.peppol_inbound_documents SET company_id = $2, status = 'routed' WHERE id = $1`,
|
|
[id, a.companyId],
|
|
)
|
|
await expect(getPool().query(
|
|
`UPDATE public.peppol_inbound_documents SET company_id = $2 WHERE id = $1`, [id, b.companyId],
|
|
)).rejects.toThrow(/cannot be re-routed/)
|
|
})
|
|
|
|
it('refuses a second copy of the same provider document and a routed status without a company', async () => {
|
|
const seeded = await seedCompany()
|
|
const providerDocumentId = randomUUID()
|
|
await insertInbound(seeded.companyId, providerDocumentId)
|
|
await expect(insertInbound(seeded.companyId, providerDocumentId))
|
|
.rejects.toThrow(/peppol_inbound_documents_provider_document_unique/)
|
|
|
|
await expect(getPool().query(
|
|
`INSERT INTO public.peppol_inbound_documents (provider, provider_document_id, document_type, status)
|
|
VALUES ('qvalia', $1, 'Invoice', 'routed')`, [randomUUID()],
|
|
)).rejects.toThrow(/peppol_inbound_documents_routed_shape/)
|
|
})
|
|
|
|
it('is visible to members of the routed company only, never unrouted rows, and only the service role writes', async () => {
|
|
const own = await seedCompany()
|
|
const other = await seedCompany()
|
|
const outsider = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: other.companyId, userId: outsider, role: 'owner' })
|
|
const ownDoc = await insertInbound(own.companyId)
|
|
await insertInbound(other.companyId)
|
|
await insertInbound(null)
|
|
|
|
const visible = await withUserContext(own.userId, async (client) => {
|
|
const { rows } = await client.query(`SELECT id FROM public.peppol_inbound_documents`)
|
|
return rows.map((row) => row.id as string)
|
|
})
|
|
expect(visible).toEqual([ownDoc])
|
|
|
|
await expect(withUserContext(own.userId, (client) =>
|
|
client.query(
|
|
`INSERT INTO public.peppol_inbound_documents (provider, provider_document_id, document_type)
|
|
VALUES ('qvalia', $1, 'Invoice')`, [randomUUID()],
|
|
),
|
|
)).rejects.toThrow(/permission denied|row-level security/)
|
|
|
|
const serviceCount = await runAsServiceRole(async (client) => {
|
|
const { rows } = await client.query(
|
|
`SELECT count(*)::int AS n FROM public.peppol_inbound_documents WHERE company_id IS NULL`,
|
|
)
|
|
return rows[0].n as number
|
|
})
|
|
expect(serviceCount).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|