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:
@@ -1149,5 +1149,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-21] RIP-4 cascade step 3 (UI): the AI booking proposal is surfaced INSIDE the existing QuickReviewDialog rather than a new inline-row card, so it reuses that dialog's proven, deterministic, balanced commit path (POST /api/transactions/[id]/categorize) instead of a parallel one. components/transactions/AiCategorizeProposal.tsx fetches POST /api/agent/categorize on dialog open (keyed on tx.id so it remounts per transaction), pre-fills accountOverride + vatTreatment via handleAccountChange (class-2 VAT clearing preserved), and shows the confidence band (säker/trolig/välj konto) + "Varför" + the candidate alternatives (click to re-apply). Falls back SILENTLY to the deterministic defaults on error, and shows a soft note on 503 (ai_unconfigured) — the dialog always works without AI. NO silent auto-posting (founder call, avoids the storno-on-undo mess): "säker" = pre-filled, one-tap Bokför via the dialog's existing button; true hands-off auto-book waits for calibration. i18n: strings inline Swedish for now (assistant surface), lift to messages/{sv,en}.json before final merge. Confidence bands (0.8/0.5) are placeholders until calibration. Needs founder visual sign-off before merge ([[project_nav_ia_redesign]]).
|
||||
[2026-08-21] RIP-4 step 4 = calibration. lib/agent/categorize/calibration.ts is the engine: isotonic regression via pool-adjacent-violators (distribution-free, monotonic) over (confidence, was_correct) samples → a calibrator that turns raw selector confidence into a probability that actually means what it says; plus reliabilityByBucket/ECE and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap (default 2000 kr) — so "säker" stays honest until proven. Measurement loop: migration 20260821100000 categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]); POST /api/agent/categorize/outcome logs one sample (proposed vs booked account → was_correct) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped to keep the corpus clean); AiCategorizeProposal surfaces the proposal metadata via onProposal. scripts/fit-categorize-calibration.ts (READ-ONLY) prints the reliability diagram + ECE + fitted calibrator once data exists — run it in a few weeks, then store the calibrator/thresholds where bandFor reads them and only THEN consider enabling auto-book. Fitting needs >=200 real samples so nothing calibrates today; the loop just starts collecting. Migration applies on merge (auto-apply-on-merge active) — not applied manually.
|
||||
[2026-08-21] The categorize selector now reads the underlag, not just the bank line — the highest-leverage quality lever for the cold-start majority (prod: 365 companies with 32.7k unbooked tx, median 0 templates, so the LLM carries them). lib/agent/categorize/underlag.ts gathers the matched receipt/invoice text (receipts.matched_transaction_id + invoice_inbox_items.matched_transaction_id + the transaction's own document_attachments), rendered as bounded Swedish text (supplier, date, total, moms, line items). Core reads these tables directly via supabase (table names, not @/extensions imports — the tables live in the shared DB). POST /api/agent/categorize gathers it server-side when the caller didn't pass `underlag`, so the model sees the actual supplier + line items. Best-effort ('' on any failure); server-side only, no client change (so no conflict with the calibration PR #1784 which also touches the dialog). Prod read (project pwxtzglxptnnvjrpixpg) also confirmed: 3342 active counterparty templates / 26k occurrences → established users get strong instant candidates; NO backfill needed (templates already reflect historical bookings).
|
||||
[2026-08-21] Peppol receiving (PR2) keeps Qvalia's consolidated partner account: every company's 0007:orgnr is registered on OUR account (PUT /partner/{p}/account/{p}/peppol/{id}) and inbound documents are routed by the AccountingCustomerParty EndpointID through peppol_registrations, because Qvalia confirmed sending needs no per-company account and child accounts would only add a 100 kr/mån tenant fee per customer; the exact received UBL XML is archived as a WORM document (upload_source e_invoice, extractionOwner none) and the inbox row is filled from the structured UBL with confidence 1 (no model pass), following the mail-hunt precedent of core inserting invoice_inbox_items directly; personnummer-based companies are refused registration until 0088 GLN support exists (publishing them would put personal data in the Peppol Directory); the poll is a 10-minute cron (GET .../readinvoices marks documents read at Qvalia, so every fetched document is archived before anything else can fail).
|
||||
[2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math.
|
||||
[2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const syncMock = vi.fn()
|
||||
const deliverMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: () => ({ from: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/lib/invoices/peppol-inbound', () => ({
|
||||
syncInboundPeppolDocuments: (...args: unknown[]) => syncMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/invoices/peppol-inbox-delivery', () => ({
|
||||
deliverPeppolDocumentToInbox: (...args: unknown[]) => deliverMock(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
function request(secret: string | null): Request {
|
||||
return new Request('http://localhost:3000/api/peppol/inbound/cron', {
|
||||
headers: secret ? { authorization: `Bearer ${secret}` } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function makeTransport(overrides: Partial<PeppolTransport> = {}): PeppolTransport {
|
||||
return {
|
||||
provider: 'qvalia',
|
||||
lookupRecipient: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
verifyWebhook: vi.fn(),
|
||||
retrieveEvidence: vi.fn(),
|
||||
listInboundDocuments: vi.fn().mockResolvedValue([]),
|
||||
fetchInboundDocumentXml: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/peppol/inbound/cron', () => {
|
||||
let unregister: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.CRON_SECRET = 'cron-secret'
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
|
||||
syncMock.mockResolvedValue({ listed: 1, archived: 1, duplicates: 0, routed: 0, unrouted: 0, delivered: 1, failed: 0, errors: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.()
|
||||
unregister = null
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
delete process.env.CRON_SECRET
|
||||
})
|
||||
|
||||
it('rejects a call without the cron secret', async () => {
|
||||
unregister = registerPeppolTransport(makeTransport())
|
||||
const response = await GET(request(null))
|
||||
expect(response.status).toBe(401)
|
||||
expect(syncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a truthful no-op when no access point is switched on', async () => {
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
const response = await GET(request('cron-secret'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ data: { skipped: true, reason: 'provider_selection_required' } })
|
||||
expect(syncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips a send-only transport', async () => {
|
||||
unregister = registerPeppolTransport(makeTransport({ listInboundDocuments: undefined }))
|
||||
const response = await GET(request('cron-secret'))
|
||||
expect(await response.json()).toEqual({ data: { skipped: true, reason: 'receiving_unsupported' } })
|
||||
})
|
||||
|
||||
it('runs the sync with the inbox deliverer and reports the summary', async () => {
|
||||
const transport = makeTransport()
|
||||
unregister = registerPeppolTransport(transport)
|
||||
const response = await GET(request('cron-secret'))
|
||||
const body = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toMatchObject({ listed: 1, delivered: 1 })
|
||||
expect(syncMock).toHaveBeenCalledTimes(1)
|
||||
const args = syncMock.mock.calls[0][0] as { transport: PeppolTransport; deliver: (d: unknown) => unknown }
|
||||
expect(args.transport).toBe(transport)
|
||||
await args.deliver({ row: {}, companyId: 'c', document: {}, xml: null })
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { syncInboundPeppolDocuments } from '@/lib/invoices/peppol-inbound'
|
||||
import { deliverPeppolDocumentToInbox } from '@/lib/invoices/peppol-inbox-delivery'
|
||||
import {
|
||||
getPeppolTransport,
|
||||
getPeppolTransportAvailability,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/peppol/inbound/cron: every 10 minutes.
|
||||
*
|
||||
* Pulls the documents the Access Point received for our registered
|
||||
* participants, archives the exact XML, routes each to its company and hands
|
||||
* it to the supplier-invoice inbox. One provider account carries every
|
||||
* company's identifier, so this is one poll for all of them; a document
|
||||
* nobody is registered for is kept as `unrouted`, never dropped.
|
||||
*
|
||||
* Truthful no-op when no access point is switched on in this environment.
|
||||
*/
|
||||
export const GET = withCronContext('cron.peppol_inbound', async (_request, ctx) => {
|
||||
const availability = getPeppolTransportAvailability()
|
||||
const transport = availability.available ? getPeppolTransport(availability.provider) : null
|
||||
if (!transport) {
|
||||
return NextResponse.json({ data: { skipped: true, reason: availability.available ? 'provider_adapter_unavailable' : availability.reason } })
|
||||
}
|
||||
if (!transport.listInboundDocuments) {
|
||||
return NextResponse.json({ data: { skipped: true, reason: 'receiving_unsupported' } })
|
||||
}
|
||||
|
||||
const service = createServiceClientNoCookies()
|
||||
const summary = await syncInboundPeppolDocuments({
|
||||
service,
|
||||
transport,
|
||||
deliver: (delivery) => deliverPeppolDocumentToInbox(service, delivery),
|
||||
log: ctx.log,
|
||||
})
|
||||
ctx.log.info('peppol inbound sync complete', { ...summary, errors: summary.errors.length })
|
||||
return NextResponse.json({ data: summary })
|
||||
})
|
||||
|
||||
export const POST = GET
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { registerPeppolTransport, type PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const service = createQueuedMockSupabase()
|
||||
const requireAuthMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => service.supabase,
|
||||
}))
|
||||
|
||||
import { DELETE, GET, POST } from '../route'
|
||||
|
||||
const user = { id: 'user-1', email: 'owner@example.test' }
|
||||
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: [],
|
||||
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',
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
describe('/api/settings/peppol', () => {
|
||||
let unregister: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
service.reset()
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
|
||||
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.()
|
||||
unregister = null
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
})
|
||||
|
||||
it('GET returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await GET(createMockRequest('/api/settings/peppol'))
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET tells the truth when no access point is switched on', async () => {
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
const response = await GET(createMockRequest('/api/settings/peppol'))
|
||||
const body = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
transport: { available: false },
|
||||
receiving_supported: false,
|
||||
registration: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('GET returns the live registration when the adapter supports receiving', async () => {
|
||||
unregister = registerPeppolTransport(makeTransport())
|
||||
enqueue({ data: [registeredRow], error: null })
|
||||
const response = await GET(createMockRequest('/api/settings/peppol'))
|
||||
const body = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.receiving_supported).toBe(true)
|
||||
expect(body.data.registration).toMatchObject({ status: 'registered', participant_identifier: '5595386219' })
|
||||
expect(body.data.registration).not.toHaveProperty('business_card')
|
||||
})
|
||||
|
||||
it('POST refuses without a transport and in the sandbox', async () => {
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
expect((await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))).status).toBe(503)
|
||||
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
|
||||
unregister = registerPeppolTransport(makeTransport())
|
||||
enqueue({ data: { is_sandbox: true }, error: null })
|
||||
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
|
||||
expect(response.status).toBe(403)
|
||||
expect((await response.json()).error.code).toBe('PEPPOL_SANDBOX_NOT_ALLOWED')
|
||||
})
|
||||
|
||||
it('POST registers the company and returns the minimized registration', async () => {
|
||||
const transport = makeTransport()
|
||||
unregister = registerPeppolTransport(transport)
|
||||
enqueue({ data: { is_sandbox: false }, error: null })
|
||||
enqueue({ data: { org_number: '559538-6219', company_name: 'Arcim Technology AB', vat_number: 'SE559538621901', city: 'Stockholm', country: 'SE' }, error: null })
|
||||
service.enqueue({ data: [], error: null }) // existing
|
||||
service.enqueue({ data: { id: 'reg-1' }, error: null }) // insert pending
|
||||
service.enqueue({ data: registeredRow, error: null }) // finalize
|
||||
|
||||
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
|
||||
const body = await response.json()
|
||||
expect(response.status).toBe(201)
|
||||
expect(body.data.registration).toMatchObject({ status: 'registered', participant_scheme: '0007' })
|
||||
expect(transport.registerRecipient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('POST maps a personnummer-based company to a 422 with the reason', async () => {
|
||||
unregister = registerPeppolTransport(makeTransport())
|
||||
enqueue({ data: { is_sandbox: false }, error: null })
|
||||
enqueue({ data: { org_number: '800101-1234', company_name: 'Firma', vat_number: null, city: null, country: 'SE' }, error: null })
|
||||
const response = await POST(createMockRequest('/api/settings/peppol', { method: 'POST' }))
|
||||
expect(response.status).toBe(422)
|
||||
expect((await response.json()).error.code).toBe('PEPPOL_REGISTRATION_PERSONAL_NUMBER')
|
||||
})
|
||||
|
||||
it('DELETE withdraws the identifier and 404s when nothing is live', async () => {
|
||||
const transport = makeTransport()
|
||||
unregister = registerPeppolTransport(transport)
|
||||
service.enqueue({ data: [registeredRow], error: null })
|
||||
service.enqueue({ data: { ...registeredRow, status: 'deregistered', deregistered_at: '2026-08-21T17:00:00.000Z' }, error: null })
|
||||
const ok = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }))
|
||||
expect(ok.status).toBe(200)
|
||||
expect((await ok.json()).data.registration.status).toBe('deregistered')
|
||||
expect(transport.unregisterRecipient).toHaveBeenCalledWith({ scheme: '0007', identifier: '5595386219' })
|
||||
|
||||
service.enqueue({ data: [], error: null })
|
||||
const missing = await DELETE(createMockRequest('/api/settings/peppol', { method: 'DELETE' }))
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { privateNoStore } from '@/lib/api/private-no-store'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import {
|
||||
deregisterCompanyFromPeppolReceiving,
|
||||
getPeppolRegistration,
|
||||
registerCompanyForPeppolReceiving,
|
||||
type PeppolRegistrationRow,
|
||||
} from '@/lib/invoices/peppol-registration'
|
||||
import {
|
||||
getPeppolTransport,
|
||||
getPeppolTransportAvailability,
|
||||
type PeppolTransport,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
function registrationPayload(row: PeppolRegistrationRow | null) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
participant_scheme: row.participant_scheme,
|
||||
participant_identifier: row.participant_identifier,
|
||||
status: row.status,
|
||||
registered_at: row.registered_at,
|
||||
deregistered_at: row.deregistered_at,
|
||||
last_error: row.last_error,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTransport(): { transport: PeppolTransport; provider: string } | null {
|
||||
const availability = getPeppolTransportAvailability()
|
||||
if (!availability.available) return null
|
||||
const transport = getPeppolTransport(availability.provider)
|
||||
return transport ? { transport, provider: availability.provider } : null
|
||||
}
|
||||
|
||||
/** GET /api/settings/peppol: receiving status for the active company. */
|
||||
export const GET = withRouteContext(
|
||||
'settings.peppol.get',
|
||||
async (_request, { supabase, companyId, log, requestId }) => {
|
||||
const availability = getPeppolTransportAvailability()
|
||||
const resolved = resolveTransport()
|
||||
try {
|
||||
const registration = resolved
|
||||
? await getPeppolRegistration({ supabase, companyId, provider: resolved.provider })
|
||||
: null
|
||||
return privateNoStore(NextResponse.json({
|
||||
data: {
|
||||
transport: availability,
|
||||
receiving_supported: !!resolved?.transport.registerRecipient,
|
||||
registration: registrationPayload(registration),
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
return privateNoStore(errorResponse(err, log, { requestId }))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** POST /api/settings/peppol: publish the company's Peppol identifier for receiving. */
|
||||
export const POST = withRouteContext(
|
||||
'settings.peppol.register',
|
||||
async (_request, { supabase, companyId, user, log, requestId }) => {
|
||||
const resolved = resolveTransport()
|
||||
if (!resolved) {
|
||||
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
|
||||
}
|
||||
if (await isSandboxCompany(supabase, companyId)) {
|
||||
return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId }))
|
||||
}
|
||||
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, company_name, vat_number, city, country')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (settingsError || !settings) {
|
||||
return privateNoStore(errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId }))
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registerCompanyForPeppolReceiving({
|
||||
service: createServiceClient(),
|
||||
companyId,
|
||||
userId: user.id,
|
||||
transport: resolved.transport,
|
||||
settings: settings as Pick<CompanySettings, 'org_number' | 'company_name' | 'vat_number' | 'city' | 'country'>,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return privateNoStore(errorResponseFromCode(result.code, log, {
|
||||
requestId,
|
||||
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
|
||||
}))
|
||||
}
|
||||
return privateNoStore(NextResponse.json({
|
||||
data: { registration: registrationPayload(result.registration) },
|
||||
}, { status: 201 }))
|
||||
} catch (err) {
|
||||
return privateNoStore(errorResponse(err, log, { requestId }))
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/** DELETE /api/settings/peppol: withdraw the identifier from the Access Point. */
|
||||
export const DELETE = withRouteContext(
|
||||
'settings.peppol.deregister',
|
||||
async (_request, { companyId, log, requestId }) => {
|
||||
const resolved = resolveTransport()
|
||||
if (!resolved) {
|
||||
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
|
||||
}
|
||||
try {
|
||||
const result = await deregisterCompanyFromPeppolReceiving({
|
||||
service: createServiceClient(),
|
||||
companyId,
|
||||
transport: resolved.transport,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return privateNoStore(errorResponseFromCode(result.code, log, {
|
||||
requestId,
|
||||
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
|
||||
}))
|
||||
}
|
||||
return privateNoStore(NextResponse.json({
|
||||
data: { registration: registrationPayload(result.registration) },
|
||||
}))
|
||||
} catch (err) {
|
||||
return privateNoStore(errorResponse(err, log, { requestId }))
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Maximize2,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
|
||||
@@ -59,7 +60,7 @@ import { copyInboxAddress, type AddressCopyState } from '@/components/extensions
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
|
||||
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
|
||||
import { selectInboxFields } from '@/lib/documents/inbox-field-visibility'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
@@ -145,7 +146,7 @@ interface InboxItem {
|
||||
// instant receipt ack) but the deferred AI extraction has not landed;
|
||||
// extracted_data is null until the realtime flip to 'received'.
|
||||
status: 'received' | 'processing' | 'error'
|
||||
source: 'email' | 'upload' | 'whatsapp'
|
||||
source: InboxItemSource
|
||||
created_at: string
|
||||
email_from: string | null
|
||||
email_subject: string | null
|
||||
@@ -2140,6 +2141,9 @@ function InboxRow({
|
||||
<Mail className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
) : item.source === 'whatsapp' ? (
|
||||
<WhatsAppMark className="h-3 w-3 shrink-0" />
|
||||
) : item.source === 'peppol' ? (
|
||||
// Received as a structured e-invoice over the Peppol network.
|
||||
<Globe className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Peppol" />
|
||||
) : item.channel_context?.mail_provider === 'gmail' ? (
|
||||
// The hunt records which mailbox it pulled a receipt from, so the
|
||||
// brand is known rather than guessed. Mail that arrived by
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
interface PeppolRegistrationView {
|
||||
participant_scheme: string
|
||||
participant_identifier: string
|
||||
status: 'pending' | 'registered' | 'failed' | 'deregistered'
|
||||
registered_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
interface PeppolSettingsPayload {
|
||||
transport: { available: boolean }
|
||||
receiving_supported: boolean
|
||||
registration: PeppolRegistrationView | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiving e-invoices via Peppol: publishes the company's 0007:orgnr through
|
||||
* the contracted Access Point. One switch, the truth about its state next to
|
||||
* it. Sending needs no registration, so this row is only about receiving.
|
||||
*/
|
||||
export function PeppolReceiveSettings() {
|
||||
const t = useTranslations('settings_peppol')
|
||||
const locale = useLocale()
|
||||
const { toast } = useToast()
|
||||
const canWrite = useCanWrite()
|
||||
const [state, setState] = useState<PeppolSettingsPayload | null>(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/peppol')
|
||||
if (!response.ok) throw new Error()
|
||||
const payload = (await response.json()) as { data?: PeppolSettingsPayload }
|
||||
if (!payload.data) throw new Error()
|
||||
setState(payload.data)
|
||||
setLoadFailed(false)
|
||||
} catch {
|
||||
setLoadFailed(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const registration = state?.registration ?? null
|
||||
const isOn = registration?.status === 'registered' || registration?.status === 'pending'
|
||||
const available = !!state?.transport.available && !!state?.receiving_supported
|
||||
|
||||
const toggle = useCallback(async (next: boolean) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/settings/peppol', { method: next ? 'POST' : 'DELETE' })
|
||||
const body = await response.json().catch(() => null) as {
|
||||
error?: { code?: string; message?: string; message_en?: string }
|
||||
} | null
|
||||
if (!response.ok) throw body?.error ?? new Error()
|
||||
toast({
|
||||
title: next ? t('toast_registered_title') : t('toast_deregistered_title'),
|
||||
description: next ? t('toast_registered_description') : t('toast_deregistered_description'),
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('toast_failed_title'),
|
||||
description: getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
await load()
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [load, locale, t, toast])
|
||||
|
||||
const statusLabel = (() => {
|
||||
if (!registration || registration.status === 'deregistered') return t('status_off')
|
||||
return t(`status_${registration.status}`)
|
||||
})()
|
||||
|
||||
return (
|
||||
<SettingsGroup label={t('heading')}>
|
||||
<SettingsRow label={t('enable_label')} help={t('enable_help')}>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={isOn}
|
||||
onCheckedChange={(value) => void toggle(value)}
|
||||
disabled={isSaving || !canWrite || !available || state === null}
|
||||
aria-label={t('enable_label')}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('status_label')} borderless>
|
||||
<div className="min-w-0 space-y-1 text-sm">
|
||||
{loadFailed ? (
|
||||
<SettingsRowNote>{t('load_failed')}</SettingsRowNote>
|
||||
) : state === null ? (
|
||||
<SettingsRowNote>{t('loading')}</SettingsRowNote>
|
||||
) : !available ? (
|
||||
<SettingsRowNote>{t('provider_required')}</SettingsRowNote>
|
||||
) : (
|
||||
<>
|
||||
<span>{statusLabel}</span>
|
||||
{registration && registration.status !== 'deregistered' && (
|
||||
<SettingsRowNote className="block tabular-nums">
|
||||
{t('peppol_id_label')} {registration.participant_scheme}:{registration.participant_identifier}
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
{registration?.status === 'failed' && registration.last_error && (
|
||||
<SettingsRowNote className="block">{registration.last_error}</SettingsRowNote>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
|
||||
import { PeppolReceiveSettings } from '@/components/settings/PeppolReceiveSettings'
|
||||
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
|
||||
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
|
||||
import { InvoiceEmailRecipientsSettings } from '@/components/settings/InvoiceEmailRecipientsSettings'
|
||||
@@ -65,6 +66,9 @@ export function InvoicingSettingsContent() {
|
||||
{/* Payment link opt-in: saves individually via toggle switch */}
|
||||
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* Peppol receiving: one switch that publishes the company's Peppol id */}
|
||||
<PeppolReceiveSettings />
|
||||
|
||||
{/* PDF settings: saves individually via toggle switches */}
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
|
||||
@@ -46,3 +46,4 @@
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
|
||||
@@ -46,3 +46,4 @@
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
|
||||
@@ -93,7 +93,14 @@ Configuration is environment-only (`PEPPOL_TRANSPORT_PROVIDER=qvalia` plus `QVAL
|
||||
|
||||
`POST /api/invoices/{id}/peppol/send` performs the send: stage the exact XML, look up the recipient, record `recipient_verified` and `submitting`, submit, record `submission_accepted` with the provider submission id, and only then issue a draft with the mark-sent semantics (`issueAndBookInvoice`: F-number, status, verifikat under faktureringsmetoden, PDF archived as underlag). A synchronous rejection is recorded as a terminal `failed` event so the identical document is never re-sent; an operational failure is `retryable_failure` and a retry is allowed. Resending an exact XML that already carries a provider submission id is an idempotent replay, never a second transmission.
|
||||
|
||||
v1 uses Qvalia's consolidated setup (every company's documents under Accounted's partner account, `accountRegNo = partnerRegNo`). Still open: per-company participant registration (`PUT /partner/{p}/account/{a}/peppol/{0007:orgnr}` with business card and Invoice + CreditNote document types), the multi-tenant child-account model if pricing favours it, credit notes, `0088` GLN for enskild firma, inbound documents, and the release-pinned validation stack.
|
||||
v1 uses Qvalia's consolidated setup (every company's documents under Accounted's partner account, `accountRegNo = partnerRegNo`). Qvalia confirmed (2026-08-21) that the sending account is irrelevant as long as `AccountingSupplierParty` carries a valid endpoint id, so no per-company child accounts are needed.
|
||||
|
||||
### Receiving (PR2)
|
||||
|
||||
- `peppol_registrations`: one live row per company and per participant; written by `POST/DELETE /api/settings/peppol` (service role after the membership check) through `lib/invoices/peppol-registration.ts`, which publishes `0007:orgnr` with the company's business card and the BIS Billing 3 Invoice + CreditNote document types via `transport.registerRecipient()`. Personnummer-based identifiers are refused (`0088` GLN pending). The switch lives in Settings > Fakturering ("E-faktura via Peppol").
|
||||
- `peppol_inbound_documents`: every document the Access Point hands us, with the exact XML (immutable, undeletable) and the provider's UBL-JSON; routed to a company by the `AccountingCustomerParty` endpoint through the registrations; states `received`, `routed`, `unrouted`, `converted`, `ignored`, `failed`.
|
||||
- `GET /api/peppol/inbound/cron` every 10 minutes: `lib/invoices/peppol-inbound.ts` lists unread invoices and credit notes, archives (`archiveInboundPeppolMessage`), routes and delivers; `lib/invoices/peppol-inbox-delivery.ts` archives the XML as a WORM document (`upload_source: 'e_invoice'`, no AI extraction), the embedded PDF when present, and creates the `invoice_inbox_items` row (`source: 'peppol'`) with the extraction filled from the structured UBL (`lib/invoices/peppol-inbound-ubl.ts`, confidence 1). The existing inbox review and convert flows take over from there.
|
||||
- Still open: the Qvalia `new_document` webhook for inbound (today polled), credit-note conversion from the inbox, `0088` GLN for enskild firma, the release-pinned validation stack, and a UI surface for `unrouted` documents.
|
||||
|
||||
### Storecove versus Qvalia (historical, pre-contract)
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -2210,6 +2210,25 @@
|
||||
"enable_help": "Shows the payment link field in the invoice editor. With Stripe connected, a payment link is created automatically when the invoice is sent.",
|
||||
"toast_save_failed": "Could not save"
|
||||
},
|
||||
"settings_peppol": {
|
||||
"heading": "E-invoicing via Peppol",
|
||||
"enable_label": "Receive e-invoices via Peppol",
|
||||
"enable_help": "Publishes the company's Peppol id (0007 + organisation number) through our Peppol access point so suppliers can send e-invoices straight into Accounted. Received invoices land in the supplier invoice inbox. Sending e-invoices needs no registration.",
|
||||
"status_label": "Status",
|
||||
"status_off": "Not registered",
|
||||
"status_pending": "Registration in progress",
|
||||
"status_registered": "Registered, receiving e-invoices",
|
||||
"status_failed": "Registration failed",
|
||||
"peppol_id_label": "Peppol id",
|
||||
"provider_required": "A contracted and configured Peppol access point is required before receiving can be enabled.",
|
||||
"loading": "Loading status…",
|
||||
"load_failed": "Could not read the Peppol status. Reload the page.",
|
||||
"toast_registered_title": "Registered for Peppol",
|
||||
"toast_registered_description": "The company's Peppol id is published. It can take a moment before it shows in the Peppol directory.",
|
||||
"toast_deregistered_title": "Deregistered from Peppol",
|
||||
"toast_deregistered_description": "The company's Peppol id has been removed at the access point.",
|
||||
"toast_failed_title": "Could not change the Peppol registration"
|
||||
},
|
||||
"settings_pdf_print": {
|
||||
"coming_soon": "Coming soon",
|
||||
"heading": "Print & PDF",
|
||||
|
||||
@@ -2210,6 +2210,25 @@
|
||||
"enable_help": "Visar betalningslänksfältet i fakturaredigeraren. Med Stripe anslutet skapas en betalningslänk automatiskt när fakturan skickas.",
|
||||
"toast_save_failed": "Kunde inte spara"
|
||||
},
|
||||
"settings_peppol": {
|
||||
"heading": "E-faktura via Peppol",
|
||||
"enable_label": "Ta emot e-fakturor via Peppol",
|
||||
"enable_help": "Publicerar bolagets Peppol-id (0007 + organisationsnummer) hos vår Peppol-operatör så att leverantörer kan skicka e-fakturor direkt till Accounted. Mottagna fakturor hamnar i leverantörsfakturainkorgen. Att skicka e-fakturor kräver ingen registrering.",
|
||||
"status_label": "Status",
|
||||
"status_off": "Inte registrerad",
|
||||
"status_pending": "Registrering pågår",
|
||||
"status_registered": "Registrerad, tar emot e-fakturor",
|
||||
"status_failed": "Registreringen misslyckades",
|
||||
"peppol_id_label": "Peppol-id",
|
||||
"provider_required": "En avtalad och konfigurerad Peppol-operatör krävs innan mottagning kan aktiveras.",
|
||||
"loading": "Hämtar status…",
|
||||
"load_failed": "Kunde inte läsa Peppol-status. Ladda om sidan.",
|
||||
"toast_registered_title": "Registrerad för Peppol",
|
||||
"toast_registered_description": "Bolagets Peppol-id är publicerat. Det kan ta en stund innan det syns i Peppol-katalogen.",
|
||||
"toast_deregistered_title": "Avregistrerad från Peppol",
|
||||
"toast_deregistered_description": "Bolagets Peppol-id är borttaget hos operatören.",
|
||||
"toast_failed_title": "Kunde inte ändra Peppol-registreringen"
|
||||
},
|
||||
"settings_pdf_print": {
|
||||
"coming_soon": "Kommer snart",
|
||||
"heading": "Utskrift & PDF",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Peppol receiving (#546, PR2): per-company participant registrations at the
|
||||
-- Access Point and the archive of inbound documents.
|
||||
--
|
||||
-- peppol_registrations: which companies publish a Peppol identifier through
|
||||
-- our provider account (Qvalia partner model: every id lives on the partner
|
||||
-- account, so the provider reference is the same for all; multi-tenant child
|
||||
-- accounts would only change that column).
|
||||
--
|
||||
-- peppol_inbound_documents: every document the Access Point hands us, with
|
||||
-- the exact received XML. A received e-invoice is räkenskapsinformation, so
|
||||
-- the payload columns are immutable and rows cannot be deleted; processing
|
||||
-- state lives beside them and is the only thing that changes.
|
||||
|
||||
CREATE TABLE public.peppol_registrations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
provider text NOT NULL CHECK (length(btrim(provider)) BETWEEN 1 AND 64),
|
||||
provider_account_reference text,
|
||||
participant_scheme text NOT NULL CHECK (participant_scheme ~ '^[0-9]{4}$'),
|
||||
participant_identifier text NOT NULL CHECK (length(btrim(participant_identifier)) BETWEEN 1 AND 64),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'registered', 'failed', 'deregistered')),
|
||||
business_card jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
document_types jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
registered_at timestamptz,
|
||||
deregistered_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peppol_registrations_status_shape CHECK (
|
||||
(status = 'registered' AND registered_at IS NOT NULL)
|
||||
OR (status = 'deregistered' AND deregistered_at IS NOT NULL)
|
||||
OR status IN ('pending', 'failed')
|
||||
)
|
||||
);
|
||||
|
||||
-- One live registration per participant and per company at a time; history
|
||||
-- rows (failed / deregistered) may accumulate.
|
||||
CREATE UNIQUE INDEX peppol_registrations_live_participant
|
||||
ON public.peppol_registrations (provider, participant_scheme, participant_identifier)
|
||||
WHERE status IN ('pending', 'registered');
|
||||
CREATE UNIQUE INDEX peppol_registrations_live_company
|
||||
ON public.peppol_registrations (company_id, provider)
|
||||
WHERE status IN ('pending', 'registered');
|
||||
CREATE INDEX peppol_registrations_company_idx
|
||||
ON public.peppol_registrations (company_id);
|
||||
|
||||
CREATE TRIGGER set_peppol_registrations_updated_at
|
||||
BEFORE UPDATE ON public.peppol_registrations
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
ALTER TABLE public.peppol_registrations ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "view own-company peppol registrations"
|
||||
ON public.peppol_registrations FOR SELECT
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
-- Writes happen through the registration route with the service role after
|
||||
-- the membership check; the browser never writes provider state directly.
|
||||
REVOKE ALL ON public.peppol_registrations FROM PUBLIC, anon;
|
||||
GRANT SELECT ON public.peppol_registrations TO authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE public.peppol_inbound_documents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider text NOT NULL CHECK (length(btrim(provider)) BETWEEN 1 AND 64),
|
||||
provider_document_id text NOT NULL CHECK (length(btrim(provider_document_id)) BETWEEN 1 AND 128),
|
||||
document_type text NOT NULL CHECK (document_type IN ('Invoice', 'CreditNote')),
|
||||
document_id text,
|
||||
issue_date date,
|
||||
due_date date,
|
||||
currency text CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$'),
|
||||
payable_amount numeric(14, 2),
|
||||
sender_scheme text CHECK (sender_scheme IS NULL OR sender_scheme ~ '^[0-9]{4}$'),
|
||||
sender_identifier text,
|
||||
sender_name text,
|
||||
recipient_scheme text CHECK (recipient_scheme IS NULL OR recipient_scheme ~ '^[0-9]{4}$'),
|
||||
recipient_identifier text,
|
||||
-- Resolved from the recipient identifier via peppol_registrations; null
|
||||
-- until routed (and stays null for a document nobody is registered for).
|
||||
company_id uuid REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||
status text NOT NULL DEFAULT 'received'
|
||||
CHECK (status IN ('received', 'routed', 'unrouted', 'converted', 'ignored', 'failed')),
|
||||
inbox_item_id uuid REFERENCES public.invoice_inbox_items(id) ON DELETE SET NULL,
|
||||
supplier_invoice_id uuid REFERENCES public.supplier_invoices(id) ON DELETE SET NULL,
|
||||
-- The exact XML archived as a WORM document (document_attachments) once
|
||||
-- the document is routed to a company; the archive is company-scoped, so it
|
||||
-- cannot exist before routing.
|
||||
xml_document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
|
||||
xml_payload text,
|
||||
xml_sha256 text CHECK (xml_sha256 IS NULL OR xml_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
ubl_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
summary jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
processed_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peppol_inbound_documents_provider_document_unique UNIQUE (provider, provider_document_id),
|
||||
CONSTRAINT peppol_inbound_documents_routed_shape CHECK (
|
||||
status IN ('received', 'unrouted', 'failed') OR company_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX peppol_inbound_documents_company_idx
|
||||
ON public.peppol_inbound_documents (company_id, received_at DESC);
|
||||
CREATE INDEX peppol_inbound_documents_status_idx
|
||||
ON public.peppol_inbound_documents (status)
|
||||
WHERE status IN ('received', 'unrouted', 'routed', 'failed');
|
||||
CREATE INDEX peppol_inbound_documents_inbox_item_idx
|
||||
ON public.peppol_inbound_documents (inbox_item_id)
|
||||
WHERE inbox_item_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER set_peppol_inbound_documents_updated_at
|
||||
BEFORE UPDATE ON public.peppol_inbound_documents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- The received document is immutable once stored: the payload, its hash, the
|
||||
-- provider correlation and what it says about itself never change; only the
|
||||
-- processing columns do. Deletion is blocked for everyone (BFL 7 kap).
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_inbound_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Inbound Peppol documents cannot be deleted (BFL 7 kap)'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF OLD.xml_payload IS NOT NULL AND NEW.xml_payload IS DISTINCT FROM OLD.xml_payload THEN
|
||||
RAISE EXCEPTION 'Inbound Peppol document payload is immutable' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF OLD.xml_sha256 IS NOT NULL AND NEW.xml_sha256 IS DISTINCT FROM OLD.xml_sha256 THEN
|
||||
RAISE EXCEPTION 'Inbound Peppol document hash is immutable' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF NEW.provider IS DISTINCT FROM OLD.provider
|
||||
OR NEW.provider_document_id IS DISTINCT FROM OLD.provider_document_id
|
||||
OR NEW.document_type IS DISTINCT FROM OLD.document_type
|
||||
OR NEW.received_at IS DISTINCT FROM OLD.received_at
|
||||
THEN
|
||||
RAISE EXCEPTION 'Inbound Peppol document identity is immutable' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF OLD.company_id IS NOT NULL AND NEW.company_id IS DISTINCT FROM OLD.company_id THEN
|
||||
RAISE EXCEPTION 'Inbound Peppol document cannot be re-routed once routed' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_inbound_immutability
|
||||
BEFORE UPDATE OR DELETE ON public.peppol_inbound_documents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_inbound_immutability();
|
||||
|
||||
ALTER TABLE public.peppol_inbound_documents ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "view own-company peppol inbound documents"
|
||||
ON public.peppol_inbound_documents FOR SELECT
|
||||
USING (company_id IS NOT NULL AND company_id IN (SELECT public.user_company_ids()));
|
||||
-- Inbound documents are written by the polling job (service role) only.
|
||||
REVOKE ALL ON public.peppol_inbound_documents FROM PUBLIC, anon;
|
||||
GRANT SELECT ON public.peppol_inbound_documents TO authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The inbox learns a new intake channel. Same DROP + ADD NOT VALID + VALIDATE
|
||||
-- idiom as the whatsapp and mail_hunt widenings, and a per-channel dedupe
|
||||
-- index keyed on the provider's document id, as the other channels have.
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
DROP CONSTRAINT IF EXISTS invoice_inbox_items_source_check;
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD CONSTRAINT invoice_inbox_items_source_check
|
||||
CHECK (source IN ('email', 'upload', 'whatsapp', 'mail_hunt', 'peppol')) NOT VALID;
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
VALIDATE CONSTRAINT invoice_inbox_items_source_check;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_inbox_peppol_document_unique
|
||||
ON public.invoice_inbox_items (company_id, (channel_context->>'peppol_document_id'))
|
||||
WHERE source = 'peppol';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -101,8 +101,13 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
|
||||
* 2026-08-17 +1: lib/import/skattekonto-file/import-service.ts inserts parsed
|
||||
* statement rows via a mapped batch (same shape as every other file importer);
|
||||
* the row shape is covered by the execute route tests and the pg-real suite.
|
||||
*
|
||||
* 2026-08-21 +1: lib/invoices/peppol-inbound.ts updates the processing state
|
||||
* of an inbound Peppol document through one helper (five literal shapes:
|
||||
* routed / unrouted / converted / failed, all partial); the column set is
|
||||
* pinned by peppol-inbound.test.ts and the pg-real immutability test.
|
||||
*/
|
||||
const UNRESOLVED_CEILING = 379
|
||||
const UNRESOLVED_CEILING = 380
|
||||
|
||||
/**
|
||||
* Floor on statically resolved column references. Guards the guard: if a change
|
||||
|
||||
+10
-2
@@ -2922,7 +2922,7 @@ export interface SIEAccountMapping {
|
||||
// receipt ack) but AI extraction has not landed yet; extracted_data is NULL
|
||||
// until the deferred worker (or the sweep cron) flips it to 'received'.
|
||||
export type InboxItemStatus = 'received' | 'processing' | 'error'
|
||||
export type InboxItemSource = 'email' | 'upload' | 'whatsapp'
|
||||
export type InboxItemSource = 'email' | 'upload' | 'whatsapp' | 'mail_hunt' | 'peppol'
|
||||
|
||||
export type CompanyInboxStatus = 'active' | 'deprecated' | 'blocked'
|
||||
|
||||
@@ -3012,7 +3012,15 @@ export interface InboxChannelContext {
|
||||
* everything else on this type belongs to the WhatsApp branch and is absent
|
||||
* on them.
|
||||
*/
|
||||
channel: 'whatsapp' | 'mail_hunt'
|
||||
channel: 'whatsapp' | 'mail_hunt' | 'peppol'
|
||||
/** Set by lib/invoices/peppol-inbox-delivery.ts: provenance of a received e-invoice. */
|
||||
peppol_provider?: string | null
|
||||
/** The provider's id for the received document (Qvalia integrationId). */
|
||||
peppol_document_id?: string | null
|
||||
peppol_document_type?: 'Invoice' | 'CreditNote' | null
|
||||
peppol_sender_endpoint?: string | null
|
||||
/** Archived exact UBL XML, when the inbox document is a rendering (embedded PDF) instead. */
|
||||
peppol_xml_document_id?: string | null
|
||||
/** Set by lib/receipt-hunt/ingest.ts: which mailbox the receipt came out of. */
|
||||
mail_mailbox?: string | null
|
||||
mail_provider?: 'gmail' | 'microsoft' | null
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
{
|
||||
"path": "/api/receipt-hunt/cron",
|
||||
"schedule": "30 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/peppol/inbound/cron",
|
||||
"schedule": "*/10 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user