diff --git a/DECISIONS.md b/DECISIONS.md index 9b76c66b..28224ddd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1149,5 +1149,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/peppol/inbound/cron/__tests__/route.test.ts b/app/api/peppol/inbound/cron/__tests__/route.test.ts new file mode 100644 index 00000000..c3391795 --- /dev/null +++ b/app/api/peppol/inbound/cron/__tests__/route.test.ts @@ -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 { + 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) + }) +}) diff --git a/app/api/peppol/inbound/cron/route.ts b/app/api/peppol/inbound/cron/route.ts new file mode 100644 index 00000000..f9f53fec --- /dev/null +++ b/app/api/peppol/inbound/cron/route.ts @@ -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 diff --git a/app/api/settings/peppol/__tests__/route.test.ts b/app/api/settings/peppol/__tests__/route.test.ts new file mode 100644 index 00000000..a82b9489 --- /dev/null +++ b/app/api/settings/peppol/__tests__/route.test.ts @@ -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 { + 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) + }) +}) diff --git a/app/api/settings/peppol/route.ts b/app/api/settings/peppol/route.ts new file mode 100644 index 00000000..0871eba0 --- /dev/null +++ b/app/api/settings/peppol/route.ts @@ -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, + }) + 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 }, +) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 7a6a692f..ac3ad7e9 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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({ ) : item.source === 'whatsapp' ? ( + ) : item.source === 'peppol' ? ( + // Received as a structured e-invoice over the Peppol network. + ) : 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 diff --git a/components/settings/PeppolReceiveSettings.tsx b/components/settings/PeppolReceiveSettings.tsx new file mode 100644 index 00000000..0fa57480 --- /dev/null +++ b/components/settings/PeppolReceiveSettings.tsx @@ -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(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 ( + + + + void toggle(value)} + disabled={isSaving || !canWrite || !available || state === null} + aria-label={t('enable_label')} + /> + + + +
+ {loadFailed ? ( + {t('load_failed')} + ) : state === null ? ( + {t('loading')} + ) : !available ? ( + {t('provider_required')} + ) : ( + <> + {statusLabel} + {registration && registration.status !== 'deregistered' && ( + + {t('peppol_id_label')} {registration.participant_scheme}:{registration.participant_identifier} + + )} + {registration?.status === 'failed' && registration.last_error && ( + {registration.last_error} + )} + + )} +
+
+
+ ) +} diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index 23022ea3..d1aa3612 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -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 */} + {/* Peppol receiving: one switch that publishes the company's Peppol id */} + + {/* PDF settings: saves individually via toggle switches */} diff --git a/docker/crontab.hosted b/docker/crontab.hosted index 45e07eac..0c2c9aa3 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -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 diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index 5d0f549d..7752e5fc 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -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 diff --git a/docs/PEPPOL_FOUNDATION.md b/docs/PEPPOL_FOUNDATION.md index e8e6fc6a..581c9076 100644 --- a/docs/PEPPOL_FOUNDATION.md +++ b/docs/PEPPOL_FOUNDATION.md @@ -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) diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index 7e00338c..29f86b4d 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -290,6 +290,15 @@ function looksLikeXhtml(bytes: Uint8Array): boolean { return head.startsWith(' = { 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 = { diff --git a/lib/invoices/__tests__/fixtures/qvalia-inbound-invoice.json b/lib/invoices/__tests__/fixtures/qvalia-inbound-invoice.json new file mode 100644 index 00000000..667d85da --- /dev/null +++ b/lib/invoices/__tests__/fixtures/qvalia-inbound-invoice.json @@ -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" +} \ No newline at end of file diff --git a/lib/invoices/__tests__/peppol-inbound-ubl.test.ts b/lib/invoices/__tests__/peppol-inbound-ubl.test.ts new file mode 100644 index 00000000..6dee7795 --- /dev/null +++ b/lib/invoices/__tests__/peppol-inbound-ubl.test.ts @@ -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() + }) +}) diff --git a/lib/invoices/__tests__/peppol-inbound.test.ts b/lib/invoices/__tests__/peppol-inbound.test.ts new file mode 100644 index 00000000..8d194d59 --- /dev/null +++ b/lib/invoices/__tests__/peppol-inbound.test.ts @@ -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 + +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 { + 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('20267497'), + ...overrides, + } +} + +function row(overrides: Partial = {}): 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: '', + 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 + 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: '20267497', + 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 + 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) + 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 + 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) + }) +}) diff --git a/lib/invoices/__tests__/peppol-inbox-delivery.test.ts b/lib/invoices/__tests__/peppol-inbox-delivery.test.ts new file mode 100644 index 00000000..8367049a --- /dev/null +++ b/lib/invoices/__tests__/peppol-inbox-delivery.test.ts @@ -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 +const document = parseUblJsonDocument(QVALIA_MESSAGE)! +const XML = '20267497' + +const { supabase: mockService, enqueue, reset, calls } = createQueuedMockSupabase() +const service = mockService as unknown as SupabaseClient + +function row(overrides: Partial = {}): 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 + 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 + 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') + }) +}) diff --git a/lib/invoices/__tests__/peppol-qvalia-transport.test.ts b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts index 0efa37f7..ea6a1234 100644 --- a/lib/invoices/__tests__/peppol-qvalia-transport.test.ts +++ b/lib/invoices/__tests__/peppol-qvalia-transport.test.ts @@ -468,3 +468,74 @@ describe('helpers', () => { expect(describeQvaliaErrorBody(null)).toBeNull() }) }) + +describe('Qvalia transport: receiving side', () => { + const fetchMock = vi.fn() + 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).accept).toBe('application/xml') + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + expect(await transport.fetchInboundDocumentXml!('in-2', 'Invoice')).toBeNull() + }) +}) diff --git a/lib/invoices/__tests__/peppol-registration.test.ts b/lib/invoices/__tests__/peppol-registration.test.ts new file mode 100644 index 00000000..cb0a200d --- /dev/null +++ b/lib/invoices/__tests__/peppol-registration.test.ts @@ -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 { + 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' }) + }) +}) diff --git a/lib/invoices/peppol-inbound-ubl.ts b/lib/invoices/peppol-inbound-ubl.ts new file mode 100644 index 00000000..e1db3ff3 --- /dev/null +++ b/lib/invoices/peppol-inbound-ubl.ts @@ -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 + +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 { + 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, + } +} diff --git a/lib/invoices/peppol-inbound.ts b/lib/invoices/peppol-inbound.ts new file mode 100644 index 00000000..b2393915 --- /dev/null +++ b/lib/invoices/peppol-inbound.ts @@ -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 + summary: Record + 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 { + 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, +): Promise { + 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 { + 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 +} diff --git a/lib/invoices/peppol-inbox-delivery.ts b/lib/invoices/peppol-inbox-delivery.ts new file mode 100644 index 00000000..dd1ee389 --- /dev/null +++ b/lib/invoices/peppol-inbox-delivery.ts @@ -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 { + 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 } +} diff --git a/lib/invoices/peppol-registration.ts b/lib/invoices/peppol-registration.ts new file mode 100644 index 00000000..60829569 --- /dev/null +++ b/lib/invoices/peppol-registration.ts @@ -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 + 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 { + 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 { + 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 { + 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 } +} diff --git a/lib/invoices/peppol-transport.ts b/lib/invoices/peppol-transport.ts index 635eb61b..2e17fd53 100644 --- a/lib/invoices/peppol-transport.ts +++ b/lib/invoices/peppol-transport.ts @@ -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 +} + +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 + 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 verifyWebhook(request: PeppolWebhookRequest): Promise retrieveEvidence(providerSubmissionId: string): Promise + /** + * Receiving side. Optional: a send-only provider leaves these undefined and + * the product keeps receiving switched off for it. + */ + registerRecipient?(input: PeppolRecipientRegistrationInput): Promise + unregisterRecipient?(participant: PeppolParticipant): Promise + listInboundDocuments?(options: PeppolInboundListOptions): Promise + /** The exact received document, for the archive (räkenskapsinformation). */ + fetchInboundDocumentXml?( + providerDocumentId: string, + documentType: PeppolInboundDocumentType, + ): Promise } const transports = new Map() diff --git a/lib/invoices/transports/qvalia.ts b/lib/invoices/transports/qvalia.ts index 24926397..30c97e29 100644 --- a/lib/invoices/transports/qvalia.ts +++ b/lib/invoices/transports/qvalia.ts @@ -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; body?: string } = {}, ): Promise { @@ -636,11 +641,106 @@ export function createQvaliaTransport( }] } + // ---- receiving side ------------------------------------------------- + + async function registerRecipient(input: PeppolRecipientRegistrationInput): Promise { + 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 { + 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 { + 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 { + 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, } } diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 8a189629..2a7acaf2 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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' }, diff --git a/messages/en.json b/messages/en.json index 6a174918..1ef17b40 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 1ad6d804..1e1f3bfd 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260821170000_peppol_receiving.sql b/supabase/migrations/20260821170000_peppol_receiving.sql new file mode 100644 index 00000000..f4f5e0af --- /dev/null +++ b/supabase/migrations/20260821170000_peppol_receiving.sql @@ -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'; diff --git a/tests/pg/peppol-receiving.pg.test.ts b/tests/pg/peppol-receiving.pg.test.ts new file mode 100644 index 00000000..ea5e3af0 --- /dev/null +++ b/tests/pg/peppol-receiving.pg.test.ts @@ -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 = '20267497' +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 = '' 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) + }) +}) diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index cc9f5364..857ed74e 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -101,8 +101,13 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * 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 diff --git a/types/index.ts b/types/index.ts index e9acd29e..39834936 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 diff --git a/vercel.json b/vercel.json index 48627803..71cc4e5a 100644 --- a/vercel.json +++ b/vercel.json @@ -93,6 +93,10 @@ { "path": "/api/receipt-hunt/cron", "schedule": "30 5 * * *" + }, + { + "path": "/api/peppol/inbound/cron", + "schedule": "*/10 * * * *" } ] }