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:
Jakob Wennberg
2026-08-21 16:56:32 +02:00
committed by GitHub
parent 99a872987e
commit f93152c397
33 changed files with 3791 additions and 7 deletions
+1
View File
@@ -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)
})
})
+48
View File
@@ -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)
})
})
+141
View File
@@ -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} />
+1
View File
@@ -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
+1
View File
@@ -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
+8 -1
View File
@@ -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)
+16
View File
@@ -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.
+36
View File
@@ -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' })
})
})
+390
View File
@@ -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,
}
}
+350
View File
@@ -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
}
+269
View File
@@ -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 }
}
+246
View File
@@ -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 }
}
+62
View File
@@ -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>()
+101 -1
View File
@@ -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,
}
}
+8
View File
@@ -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' },
+19
View File
@@ -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",
+19
View File
@@ -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';
+157
View File
@@ -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)
})
})
+6 -1
View File
@@ -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
View File
@@ -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
+4
View File
@@ -93,6 +93,10 @@
{
"path": "/api/receipt-hunt/cron",
"schedule": "30 5 * * *"
},
{
"path": "/api/peppol/inbound/cron",
"schedule": "*/10 * * * *"
}
]
}