feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation * fix(invoices): harden Peppol compliance guards * fix(api): narrow Peppol document loading * test(pg): hash Peppol fixture payload * fix(invoices): address Peppol review findings * test(pg): isolate Peppol provider events * test(pg): isolate Peppol submission fixtures
This commit is contained in:
@@ -935,6 +935,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Skeptic refutation fix on the renewal guard: the picker's gap-fill probe keys its state by connectionId and lists `accounts` in its deps, so the pre-existing reset effect (re-runs on every accounts identity change, e.g. the panel's visibility refetch after a BankID app switch) can never wipe the renewal default without a matching re-probe. Backfill sweep hardening from the same pass: accounts whose cash_accounts row did not resolve (found: false) are skipped instead of degrading to the pooled currency-only form, and the sweep window opens at the oldest returned booking date when the bank over-returns history. resolveGapFillStart clamps to the backend's 365-day floor so the shown date always matches the actual backfill start.
|
||||
[2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client.
|
||||
[2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees.
|
||||
[2026-08-13] Issue #546 Peppol transport foundation uses separate immutable staging, event, and evidence records with a disabled send gate: Corner 3 transport is not buyer acceptance, raw out-of-order events must remain auditable, and Storecove or Qvalia credentials plus a verified webhook contract are still external product inputs.
|
||||
[2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end.
|
||||
[2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment.
|
||||
[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales.
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
CheckCircle,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -160,6 +161,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isDownloadingPeppol, setIsDownloadingPeppol] = useState(false)
|
||||
const [isPreparingPeppol, setIsPreparingPeppol] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false)
|
||||
@@ -628,6 +630,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePeppolDelivery() {
|
||||
if (!invoice) return
|
||||
setIsPreparingPeppol(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/peppol`, { method: 'POST' })
|
||||
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(t('peppol_prepare_failed_description'))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('peppol_prepared_title'),
|
||||
description: t('peppol_prepared_description'),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('peppol_prepare_failed_title'),
|
||||
description: error instanceof Error
|
||||
? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' })
|
||||
: getUserErrorMessage(error, {
|
||||
context: 'invoice',
|
||||
locale: locale.startsWith('sv') ? 'sv' : 'en',
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsPreparingPeppol(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one specific document in the browser instead of saving it (#1190):
|
||||
* granskning should not require leaving the app for the Downloads folder.
|
||||
@@ -1071,18 +1106,40 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</>
|
||||
)}
|
||||
{!isSelfBilled && isRealInvoice && !isCreditNote && invoice.invoice_number && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={downloadPeppolXml}
|
||||
disabled={isDownloadingPeppol}
|
||||
>
|
||||
{isDownloadingPeppol ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_peppol_xml')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={downloadPeppolXml}
|
||||
disabled={isDownloadingPeppol}
|
||||
>
|
||||
{isDownloadingPeppol ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_peppol_xml')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={preparePeppolDelivery}
|
||||
disabled={isPreparingPeppol || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isPreparingPeppol ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileCheck2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('prepare_peppol_delivery')}
|
||||
</Button>
|
||||
<span className="inline-flex" title={t('peppol_provider_required')}>
|
||||
<Button variant="outline" disabled>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('send_via_peppol')}
|
||||
</Button>
|
||||
<span className="sr-only">{t('peppol_provider_required')}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,11 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const user = { id: 'user-1', email: 'owner@example.test' }
|
||||
@@ -153,3 +157,118 @@ describe('GET /api/invoices/[id]/peppol', () => {
|
||||
expect(xml).toContain('<cbc:PayableAmount currencyID="SEK">125.00</cbc:PayableAmount>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices/[id]/peppol', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
it('returns 401 when the caller is not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid invoice id', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/not-a-uuid/peppol', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'not-a-uuid' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not exist in the active company', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('stages the exact XML but truthfully reports that nothing was sent', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
invoice_id: INVOICE_ID,
|
||||
idempotency_key: '33333333-3333-4333-8333-333333333333',
|
||||
xml_sha256: 'a'.repeat(64),
|
||||
status: 'staged',
|
||||
filename: 'peppol-invoice-F-2026-42.xml',
|
||||
created_at: '2026-08-13T16:00:00.000Z',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(body.data).toMatchObject({
|
||||
status: 'staged',
|
||||
network_submitted: false,
|
||||
transport: {
|
||||
available: false,
|
||||
provider: null,
|
||||
reason: 'provider_selection_required',
|
||||
},
|
||||
})
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith(
|
||||
'stage_peppol_delivery',
|
||||
expect.objectContaining({
|
||||
p_company_id: 'company-1',
|
||||
p_invoice_id: INVOICE_ID,
|
||||
p_recipient_scheme: '0007',
|
||||
p_recipient_identifier: '5566778899',
|
||||
p_xml_payload: expect.stringContaining('<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>'),
|
||||
p_xml_sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['42501', 403, 'FORBIDDEN'],
|
||||
['P0002', 404, 'NOT_FOUND'],
|
||||
])('preserves staging SQLSTATE %s as an expected API response', async (
|
||||
code,
|
||||
expectedStatus,
|
||||
expectedCode,
|
||||
) => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: null, error: { code, message: 'staging rejected' } })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(body.error.code).toBe(expectedCode)
|
||||
expect(body.error.details).toMatchObject({ pgCode: code })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const requireAuthMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const user = { id: 'user-1', email: 'owner@example.test' }
|
||||
|
||||
describe('GET /api/invoices/[id]/peppol/deliveries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
it('returns 401 when the caller is not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid invoice id', async () => {
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/not-a-uuid/peppol/deliveries'),
|
||||
createMockRouteParams({ id: 'not-a-uuid' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 for an invoice outside the active company', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns minimized delivery status and a truthful transport gate', async () => {
|
||||
enqueue({ data: { id: INVOICE_ID }, error: null })
|
||||
enqueue({
|
||||
data: [{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
status: 'transport_succeeded',
|
||||
provider: 'storecove',
|
||||
xml_sha256: 'a'.repeat(64),
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
|
||||
createMockRouteParams({ id: INVOICE_ID }),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(body.data).toEqual([
|
||||
expect.objectContaining({ status: 'transport_succeeded', provider: 'storecove' }),
|
||||
])
|
||||
expect(body.transport).toEqual({
|
||||
available: false,
|
||||
provider: null,
|
||||
reason: 'provider_selection_required',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
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 { listPeppolDeliverySummaries } from '@/lib/invoices/peppol-delivery'
|
||||
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const paramsSchema = z.object({ id: z.uuid() })
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.peppol.deliveries.list',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const parsedParams = paramsSchema.safeParse(await params)
|
||||
if (!parsedParams.success) {
|
||||
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
details: { fields: parsedParams.error.flatten().fieldErrors },
|
||||
}))
|
||||
}
|
||||
const invoiceId = parsedParams.data.id
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('id')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invoiceError || !invoice) {
|
||||
return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
try {
|
||||
const deliveries = await listPeppolDeliverySummaries({
|
||||
supabase,
|
||||
companyId,
|
||||
invoiceId,
|
||||
})
|
||||
return privateNoStore(NextResponse.json({
|
||||
data: deliveries,
|
||||
transport: getPeppolTransportAvailability(),
|
||||
}))
|
||||
} catch (err) {
|
||||
return privateNoStore(errorResponse(err, log, { requestId }))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,16 +1,110 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import { privateNoStore } from '@/lib/api/private-no-store'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { generatePeppolBisBillingInvoice } from '@/lib/invoices/peppol-bis-billing'
|
||||
import { stagePeppolDelivery } from '@/lib/invoices/peppol-delivery'
|
||||
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
|
||||
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
const paramsSchema = z.object({ id: z.uuid() })
|
||||
type GeneratedPeppolInvoice = Extract<
|
||||
ReturnType<typeof generatePeppolBisBillingInvoice>,
|
||||
{ ok: true }
|
||||
>
|
||||
type LoadPeppolDocumentResult =
|
||||
| { ok: true; document: GeneratedPeppolInvoice }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
async function loadPeppolDocument(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
log: Parameters<typeof errorResponseFromCode>[1]
|
||||
requestId: string
|
||||
}): Promise<LoadPeppolDocumentResult> {
|
||||
const { data: invoice, error: invoiceError } = await args.supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', args.invoiceId)
|
||||
.eq('company_id', args.companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode(
|
||||
'INVOICE_NOT_FOUND',
|
||||
args.log,
|
||||
{ requestId: args.requestId },
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
const { data: company, error: companyError } = await args.supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', args.companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode(
|
||||
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
|
||||
args.log,
|
||||
{ requestId: args.requestId },
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] }
|
||||
if (!typedInvoice.customer) {
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
|
||||
requestId: args.requestId,
|
||||
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
|
||||
messageEn: 'The invoice has no customer available for Peppol export.',
|
||||
details: { field: 'invoice.customer' },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const document = generatePeppolBisBillingInvoice({
|
||||
invoice: typedInvoice,
|
||||
customer: typedInvoice.customer,
|
||||
items: typedInvoice.items ?? [],
|
||||
company: company as CompanySettings,
|
||||
})
|
||||
if (!document.ok) {
|
||||
const first = document.issues[0]
|
||||
return {
|
||||
ok: false,
|
||||
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
|
||||
requestId: args.requestId,
|
||||
messageSv: first?.messageSv,
|
||||
messageEn: first?.messageEn,
|
||||
details: {
|
||||
issues: document.issues.map((item) => ({
|
||||
code: item.code,
|
||||
field: item.field,
|
||||
message_sv: item.messageSv,
|
||||
message_en: item.messageEn,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, document }
|
||||
}
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
@@ -25,67 +119,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
const { id } = parsedParams.data
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }))
|
||||
}
|
||||
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return privateNoStore(errorResponseFromCode(
|
||||
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
|
||||
log,
|
||||
{ requestId },
|
||||
))
|
||||
}
|
||||
|
||||
const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] }
|
||||
if (!typedInvoice.customer) {
|
||||
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
|
||||
messageEn: 'The invoice has no customer available for Peppol export.',
|
||||
details: { field: 'invoice.customer' },
|
||||
}))
|
||||
}
|
||||
|
||||
const result = generatePeppolBisBillingInvoice({
|
||||
invoice: typedInvoice,
|
||||
customer: typedInvoice.customer,
|
||||
items: typedInvoice.items ?? [],
|
||||
company: company as CompanySettings,
|
||||
})
|
||||
if (!result.ok) {
|
||||
const first = result.issues[0]
|
||||
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
messageSv: first?.messageSv,
|
||||
messageEn: first?.messageEn,
|
||||
details: {
|
||||
issues: result.issues.map((item) => ({
|
||||
code: item.code,
|
||||
field: item.field,
|
||||
message_sv: item.messageSv,
|
||||
message_en: item.messageEn,
|
||||
})),
|
||||
},
|
||||
}))
|
||||
}
|
||||
const loaded = await loadPeppolDocument({ supabase, companyId, invoiceId: id, log, requestId })
|
||||
if (!loaded.ok) return loaded.response
|
||||
const result = loaded.document
|
||||
|
||||
return new NextResponse(result.xml, {
|
||||
status: 200,
|
||||
@@ -98,3 +134,49 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.peppol.stage',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const parsedParams = paramsSchema.safeParse(await params)
|
||||
if (!parsedParams.success) {
|
||||
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
details: { fields: parsedParams.error.flatten().fieldErrors },
|
||||
}))
|
||||
}
|
||||
|
||||
const invoiceId = parsedParams.data.id
|
||||
const loaded = await loadPeppolDocument({
|
||||
supabase,
|
||||
companyId,
|
||||
invoiceId,
|
||||
log,
|
||||
requestId,
|
||||
})
|
||||
if (!loaded.ok) return loaded.response
|
||||
|
||||
try {
|
||||
const delivery = await stagePeppolDelivery({
|
||||
supabase,
|
||||
companyId,
|
||||
invoiceId,
|
||||
document: loaded.document,
|
||||
})
|
||||
return privateNoStore(NextResponse.json({
|
||||
data: {
|
||||
id: delivery.id,
|
||||
idempotency_key: delivery.idempotency_key,
|
||||
xml_sha256: delivery.xml_sha256,
|
||||
status: delivery.status,
|
||||
created_at: delivery.created_at,
|
||||
network_submitted: false,
|
||||
transport: getPeppolTransportAvailability(),
|
||||
},
|
||||
}, { status: 201 }))
|
||||
} catch (err) {
|
||||
return privateNoStore(errorResponse(err, log, { requestId }))
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -5,12 +5,25 @@ Issue #546 requires two separable capabilities:
|
||||
1. Produce a correctly structured Peppol BIS Billing 3 invoice from Accounted data.
|
||||
2. Deliver and receive documents through the Peppol network.
|
||||
|
||||
This change implements the first capability for a deliberately constrained Swedish invoice profile. It does not claim network delivery.
|
||||
The first slice implemented the invoice profile. The second slice adds an immutable staged-delivery and audit foundation. Neither slice claims network delivery.
|
||||
|
||||
## Implemented scope
|
||||
|
||||
`GET /api/invoices/{id}/peppol` produces a UBL 2.1 invoice with the Peppol BIS Billing 3 CustomizationID and ProfileID. It is available to an authenticated member of the active company and applies the same explicit `company_id` isolation as other invoice routes.
|
||||
|
||||
`POST /api/invoices/{id}/peppol` now stores the exact generated XML as an immutable staged delivery. Staging assigns a stable UUID idempotency key, stores the recipient, profile identifiers, filename, SHA-256, retention date, and an append-only local audit event. It explicitly returns `network_submitted: false`. Repeating the request for the same invoice and XML returns the existing staged record.
|
||||
|
||||
`GET /api/invoices/{id}/peppol/deliveries` returns a minimized status timeline projection without exposing XML, raw webhooks, or provider evidence. The invoice page can prepare a delivery, but its network send control remains disabled with a provider-required explanation.
|
||||
|
||||
The provider-neutral `PeppolTransport` boundary separates:
|
||||
|
||||
- recipient lookup and advertised document/process capabilities;
|
||||
- idempotent submission and provider correlation;
|
||||
- cryptographically verified webhook normalization;
|
||||
- evidence retrieval, including an optional exact transmitted document.
|
||||
|
||||
No adapter is registered by core and no environment value can make an absent adapter appear available.
|
||||
|
||||
The export supports:
|
||||
|
||||
- numbered standard sales invoices, not credit notes, self-billing, proformas, or delivery notes;
|
||||
@@ -49,11 +62,13 @@ A provider adapter needs at least:
|
||||
- authenticated webhooks or polling for final transport status;
|
||||
- test and production environments with equivalent validation behavior.
|
||||
|
||||
The existing email delivery model cannot honestly represent Peppol receipts. Provider selection should precede the database design because provider status vocabularies and webhook guarantees determine the durable state machine.
|
||||
The existing email delivery model cannot honestly represent Peppol receipts. The Peppol-specific model therefore records `staged`, recipient lookup, submission acceptance, Corner 3 transport success, recipient acknowledgement, and business acceptance or rejection separately. Every verified raw event remains append-only even when it is duplicated or arrives out of order. The latest normalized status is only a projection.
|
||||
|
||||
### Status and audit handling
|
||||
|
||||
After provider selection, add a migration and pg-real tests for a Peppol-specific delivery record. It should preserve the exact submitted XML, its SHA-256 hash, recipient scheme and identifier, provider id, attempt timestamps, normalized status, raw receipt metadata, and immutable failure history. A successful API acceptance is not the same as network delivery.
|
||||
The Peppol-specific tables now preserve the exact staged XML, its SHA-256 hash, recipient scheme and identifier, provider and tenant correlation, attempt timestamps, normalized status, raw verified event metadata, immutable failure history, and retrieved evidence. A successful API acceptance is not the same as Corner 3 transport, and Corner 3 transport is not the same as buyer acknowledgement or business acceptance.
|
||||
|
||||
Provider event and evidence RPCs are service-role only. Future webhook routes must first use the selected adapter to authenticate and normalize the provider payload, then persist the verified event. A public webhook endpoint is intentionally not exposed before its authentication contract is known.
|
||||
|
||||
No invoice status should change merely because XML was generated or accepted by a provider. The send workflow must define exactly which provider receipt constitutes delivery, how retries preserve idempotency, and how a permanent rejection is shown without mutating the posted invoice.
|
||||
|
||||
@@ -63,11 +78,19 @@ Inbound invoices are a separate acceptance slice. It requires provider webhook a
|
||||
|
||||
### UI and API
|
||||
|
||||
The current UI downloads a locally checked XML file and states that it was not sent. Once delivery exists, sending should be a distinct confirmation flow that performs recipient lookup, shows the discovered recipient, and records the resulting delivery timeline. The download should remain available for diagnosis and interoperability testing.
|
||||
The UI downloads a locally checked XML file and can prepare an immutable delivery snapshot. Both actions state that they did not send the invoice. Once an adapter exists, sending must be a distinct confirmation flow that performs recipient lookup, shows the discovered participant and capabilities, and records the resulting timeline. The download remains available for diagnosis and interoperability testing.
|
||||
|
||||
### Credentials and commercial decision
|
||||
|
||||
Emil must choose and contract a certified access-point provider before full send or receive can be completed. The decision needs verified answers for:
|
||||
Emil must choose and contract a certified access-point provider before full send or receive can be completed.
|
||||
|
||||
### Storecove versus Qvalia
|
||||
|
||||
Storecove is the stronger fit for the lifecycle already modeled. Its official API documents recipient discovery, caller-supplied `idempotencyGuid`, a returned submission `guid`, tenant correlation, asynchronous sending webhooks, and a dedicated evidence endpoint. Its sandbox supports webhook simulation and the OpenPeppol test network. A Storecove adapter still requires a commercial contract and credentials; these public semantics do not prove Accounted's tenant is authorized or onboarded.
|
||||
|
||||
Qvalia is a Swedish certified Access Point and SMP with an explicit partner and multi-tenant offering. Its public quick start documents production and sandbox endpoints, account registration numbers, and separate keys. Public material does not currently specify a Storecove-equivalent contract for idempotency, signed webhooks, event ordering, or exact transmitted-document evidence. Those points must be obtained from Qvalia Sales or Support before an adapter can be production quality.
|
||||
|
||||
Inputs required for either selection:
|
||||
|
||||
- multitenant or reseller authorization for Accounted customer companies;
|
||||
- setup, monthly, per-document, inbound, lookup, and support pricing;
|
||||
@@ -77,6 +100,16 @@ Emil must choose and contract a certified access-point provider before full send
|
||||
- webhook signing, retention, service levels, and data-processing terms;
|
||||
- support for the mandatory May 2026 Peppol release.
|
||||
|
||||
Additional API contract inputs required before implementation:
|
||||
|
||||
- the external tenant, legal entity, and account identifiers for every Accounted company;
|
||||
- exact discovery request and response semantics for `0007` and `0088` participants;
|
||||
- idempotency retention, duplicate response behavior, and retry guarantees;
|
||||
- webhook signature or authentication scheme, secret rotation, replay window, event identifiers, retry policy, and ordering guarantees;
|
||||
- exact meanings of transport, acknowledgement, acceptance, rejection, and terminal events;
|
||||
- evidence endpoint response, retention, exact-document guarantees, and audit export format;
|
||||
- sandbox participant IDs, production onboarding checks, and agreed conformance acceptance tests.
|
||||
|
||||
Provider credentials and prices are external operational inputs. They are not invented, stored in source, or represented by a fake provider in this change.
|
||||
|
||||
## Primary specifications and authority guidance
|
||||
@@ -87,3 +120,7 @@ Provider credentials and prices are external operational inputs. They are not in
|
||||
- [European Commission EN 16931 validation artefacts](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931)
|
||||
- [SFTI Peppol BIS Billing 3 guidance](https://www.sfti.se/sfti/standarder/peppolbisochpeppolinfrastruktur/peppolbisbilling3.26609.html)
|
||||
- [Swedish authority guidance for connecting to Peppol](https://www.upphandlingsmyndigheten.se/inkopsprocessen/e-handel/peppol/anslut-till-peppol/)
|
||||
- [Storecove API documentation](https://www.storecove.com/docs/)
|
||||
- [Qvalia API quick start](https://api.qvalia.io/quick-start)
|
||||
- [Qvalia API environments and formats](https://api.qvalia.io/api-documentation/apis)
|
||||
- [Qvalia partner API and Peppol infrastructure](https://qvalia.com/help/overview-of-qvalias-partner-api-and-peppol-infrastructure/)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NextResponse } from 'next/server'
|
||||
|
||||
export function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
@@ -101,6 +101,15 @@ describe('errorResponse', () => {
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23505' })
|
||||
})
|
||||
|
||||
it('maps Postgres no-data-found to NOT_FOUND with pgCode', async () => {
|
||||
const pgErr = Object.assign(new Error('invoice not found'), { code: 'P0002' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_not_found' })
|
||||
expect(res.status).toBe(404)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
expect(body.error.details).toMatchObject({ pgCode: 'P0002' })
|
||||
})
|
||||
|
||||
it('falls back to INTERNAL_ERROR for unknown shapes', async () => {
|
||||
const res = errorResponse(new Error('boom'), noopLogger, { requestId: 'req_5' })
|
||||
expect(res.status).toBe(500)
|
||||
|
||||
@@ -290,6 +290,7 @@ function postgresCodeToStructured(code: string): string | null {
|
||||
case '42501':
|
||||
return 'FORBIDDEN'
|
||||
case '42P01':
|
||||
case 'P0002':
|
||||
return 'NOT_FOUND'
|
||||
case '40001':
|
||||
case '40P01':
|
||||
|
||||
@@ -245,4 +245,35 @@ describe('generatePeppolBisBillingInvoice', () => {
|
||||
'SUPPLIER_PARTICIPANT_IDENTIFIER_UNSUPPORTED',
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects a personnummer-only buyer instead of labeling it as scheme 0007', () => {
|
||||
const input = makeValidInput()
|
||||
input.customer = makeCustomer({ ...input.customer, org_number: '800101-1231' })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain(
|
||||
'BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects credit notes and self-billed invoices in the generation layer', () => {
|
||||
for (const invoice of [
|
||||
makeInvoice({
|
||||
...makeValidInput().invoice,
|
||||
credited_invoice_id: '22222222-2222-4222-8222-222222222222',
|
||||
}),
|
||||
makeInvoice({ ...makeValidInput().invoice, is_self_billed: true }),
|
||||
]) {
|
||||
const input = makeValidInput()
|
||||
input.invoice = invoice
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) continue
|
||||
expect(result.issues.map(({ code }) => code)).toContain('DOCUMENT_TYPE_UNSUPPORTED')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sha256Hex } from '../peppol-delivery'
|
||||
|
||||
describe('sha256Hex', () => {
|
||||
it('produces a stable lowercase fingerprint for the exact XML bytes', () => {
|
||||
expect(sha256Hex('<Invoice>åäö</Invoice>')).toBe(
|
||||
'ab1c7c9e3a2780e73140e40a0af1a1d355026e3b754049bc6b0e8d03490b4d65',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
getPeppolTransport,
|
||||
getPeppolTransportAvailability,
|
||||
registerPeppolTransport,
|
||||
type PeppolTransport,
|
||||
} from '../peppol-transport'
|
||||
|
||||
function makeTransport(provider: string): PeppolTransport {
|
||||
return {
|
||||
provider,
|
||||
lookupRecipient: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
verifyWebhook: vi.fn(),
|
||||
retrieveEvidence: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Peppol transport registry', () => {
|
||||
const cleanups: Array<() => void> = []
|
||||
const originalProvider = process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) cleanups.pop()?.()
|
||||
if (originalProvider === undefined) {
|
||||
delete process.env.PEPPOL_TRANSPORT_PROVIDER
|
||||
} else {
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = originalProvider
|
||||
}
|
||||
})
|
||||
|
||||
it('stays truthfully unavailable until a provider is selected', () => {
|
||||
expect(getPeppolTransportAvailability()).toEqual({
|
||||
available: false,
|
||||
provider: null,
|
||||
reason: 'provider_selection_required',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not claim availability for a configured but absent adapter', () => {
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'storecove'
|
||||
|
||||
expect(getPeppolTransportAvailability()).toEqual({
|
||||
available: false,
|
||||
provider: null,
|
||||
reason: 'provider_adapter_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('registers and removes an explicit adapter without a core default', () => {
|
||||
const transport = makeTransport('Storecove')
|
||||
cleanups.push(registerPeppolTransport(transport))
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'storecove'
|
||||
|
||||
expect(getPeppolTransport('STORECOVE')).toBe(transport)
|
||||
expect(getPeppolTransportAvailability()).toEqual({
|
||||
available: true,
|
||||
provider: 'storecove',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate provider registrations', () => {
|
||||
cleanups.push(registerPeppolTransport(makeTransport('qvalia')))
|
||||
expect(() => registerPeppolTransport(makeTransport('QVALIA')))
|
||||
.toThrow('Peppol transport already registered: qvalia')
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,13 @@ export interface PeppolInvoiceInput {
|
||||
}
|
||||
|
||||
export type PeppolInvoiceResult =
|
||||
| { ok: true; xml: string; filename: string }
|
||||
| {
|
||||
ok: true
|
||||
xml: string
|
||||
filename: string
|
||||
sender: { scheme: '0007'; identifier: string }
|
||||
recipient: { scheme: '0007'; identifier: string }
|
||||
}
|
||||
| { ok: false; issues: PeppolValidationIssue[] }
|
||||
|
||||
interface PreparedParty {
|
||||
@@ -621,5 +627,7 @@ export function generatePeppolBisBillingInvoice(input: PeppolInvoiceInput): Pepp
|
||||
ok: true,
|
||||
xml: renderInvoiceXml(input, validation.prepared),
|
||||
filename: `peppol-invoice-${filenameNumber}.xml`,
|
||||
sender: { scheme: '0007', identifier: validation.prepared.supplier.orgNumber },
|
||||
recipient: { scheme: '0007', identifier: validation.prepared.buyer.orgNumber },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
PEPPOL_BIS_BILLING_CUSTOMIZATION_ID,
|
||||
PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
type PeppolInvoiceResult,
|
||||
} from '@/lib/invoices/peppol-bis-billing'
|
||||
import type {
|
||||
PeppolDeliveryEvidence,
|
||||
PeppolVerifiedEvent,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
|
||||
type GeneratedPeppolInvoice = Extract<PeppolInvoiceResult, { ok: true }>
|
||||
|
||||
export interface PeppolDeliverySummary {
|
||||
id: string
|
||||
idempotency_key: string
|
||||
recipient_scheme: string
|
||||
recipient_identifier: string
|
||||
xml_sha256: string
|
||||
provider: string | null
|
||||
provider_submission_id: string | null
|
||||
status: string
|
||||
status_at: string
|
||||
status_detail: string | null
|
||||
submitted_at: string | null
|
||||
terminal_at: string | null
|
||||
evidence_retrieved_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
export interface StagedPeppolDelivery extends PeppolDeliverySummary {
|
||||
invoice_id: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export async function stagePeppolDelivery(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
document: GeneratedPeppolInvoice
|
||||
}): Promise<StagedPeppolDelivery> {
|
||||
const { data, error } = await args.supabase.rpc('stage_peppol_delivery', {
|
||||
p_company_id: args.companyId,
|
||||
p_invoice_id: args.invoiceId,
|
||||
p_recipient_scheme: args.document.recipient.scheme,
|
||||
p_recipient_identifier: args.document.recipient.identifier,
|
||||
p_customization_id: PEPPOL_BIS_BILLING_CUSTOMIZATION_ID,
|
||||
p_profile_id: PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
p_filename: args.document.filename,
|
||||
p_xml_payload: args.document.xml,
|
||||
p_xml_sha256: sha256Hex(args.document.xml),
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
if (!data) throw new Error('Failed to stage Peppol delivery: no data returned')
|
||||
return data as StagedPeppolDelivery
|
||||
}
|
||||
|
||||
export async function listPeppolDeliverySummaries(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
}): Promise<PeppolDeliverySummary[]> {
|
||||
const { data, error } = await args.supabase.rpc('list_peppol_delivery_summaries', {
|
||||
p_company_id: args.companyId,
|
||||
p_invoice_id: args.invoiceId,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`Failed to list Peppol deliveries: ${error.message}`)
|
||||
}
|
||||
return (data ?? []) as PeppolDeliverySummary[]
|
||||
}
|
||||
|
||||
export async function persistVerifiedPeppolEvent(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
event: PeppolVerifiedEvent
|
||||
}): Promise<PeppolDeliverySummary> {
|
||||
const { event } = args
|
||||
const { data, error } = await args.supabase.rpc('record_peppol_delivery_event', {
|
||||
p_company_id: args.companyId,
|
||||
p_idempotency_key: event.idempotencyKey,
|
||||
p_provider: event.provider,
|
||||
p_provider_tenant_id: event.providerTenantId,
|
||||
p_provider_submission_id: event.providerSubmissionId,
|
||||
p_provider_event_id: event.providerEventId,
|
||||
p_provider_event_code: event.eventCode,
|
||||
p_normalized_status: event.normalizedStatus,
|
||||
p_is_terminal: event.isTerminal,
|
||||
p_detail: event.detail,
|
||||
p_raw_payload: event.rawPayload,
|
||||
p_event_sha256: event.eventSha256,
|
||||
p_verification_method: event.verificationMethod,
|
||||
p_occurred_at: event.occurredAt,
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to record Peppol event: ${error?.message ?? 'unknown error'}`)
|
||||
}
|
||||
return data as PeppolDeliverySummary
|
||||
}
|
||||
|
||||
export async function persistPeppolEvidence(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
idempotencyKey: string
|
||||
evidence: PeppolDeliveryEvidence
|
||||
}): Promise<string> {
|
||||
const { evidence } = args
|
||||
const { data, error } = await args.supabase.rpc('record_peppol_delivery_evidence', {
|
||||
p_company_id: args.companyId,
|
||||
p_idempotency_key: args.idempotencyKey,
|
||||
p_provider: evidence.provider,
|
||||
p_evidence_type: evidence.evidenceType,
|
||||
p_evidence_payload: evidence.payload,
|
||||
p_document_payload: evidence.exactDocument,
|
||||
p_document_sha256: evidence.exactDocumentSha256,
|
||||
p_evidence_sha256: evidence.evidenceSha256,
|
||||
p_retrieved_at: evidence.retrievedAt,
|
||||
})
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to record Peppol evidence: ${error?.message ?? 'unknown error'}`)
|
||||
}
|
||||
return data as string
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Provider-neutral boundary between Accounted's Peppol lifecycle and a
|
||||
* contracted Access Point. Core code must not infer network delivery from an
|
||||
* HTTP response: submissions, asynchronous events, and evidence are distinct.
|
||||
*/
|
||||
|
||||
export type PeppolDeliveryStatus =
|
||||
| 'staged'
|
||||
| 'recipient_verified'
|
||||
| 'submitting'
|
||||
| 'retryable_failure'
|
||||
| 'submission_accepted'
|
||||
| 'transport_succeeded'
|
||||
| 'recipient_acknowledged'
|
||||
| 'business_accepted'
|
||||
| 'business_rejected'
|
||||
| 'no_route'
|
||||
| 'failed'
|
||||
|
||||
export interface PeppolParticipant {
|
||||
scheme: string
|
||||
identifier: string
|
||||
}
|
||||
export interface PeppolRecipientCapability {
|
||||
documentTypeId: string
|
||||
processId: string
|
||||
}
|
||||
|
||||
export type PeppolRecipientLookup =
|
||||
| {
|
||||
reachable: true
|
||||
participant: PeppolParticipant
|
||||
capabilities: PeppolRecipientCapability[]
|
||||
checkedAt: string
|
||||
}
|
||||
| {
|
||||
reachable: false
|
||||
participant: PeppolParticipant
|
||||
reasonCode: string
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export interface PeppolSubmission {
|
||||
idempotencyKey: string
|
||||
tenantReference: string
|
||||
sender: PeppolParticipant
|
||||
recipient: PeppolParticipant
|
||||
documentTypeId: string
|
||||
processId: string
|
||||
filename: string
|
||||
contentType: 'application/xml'
|
||||
document: string
|
||||
documentSha256: string
|
||||
}
|
||||
|
||||
export interface PeppolSubmissionReceipt {
|
||||
provider: string
|
||||
providerSubmissionId: string
|
||||
idempotencyKey: string
|
||||
tenantReference: string
|
||||
acceptedAt: string
|
||||
}
|
||||
|
||||
export interface PeppolVerifiedEvent {
|
||||
provider: string
|
||||
providerTenantId: string | null
|
||||
providerSubmissionId: string | null
|
||||
providerEventId: string | null
|
||||
idempotencyKey: string
|
||||
eventCode: string
|
||||
normalizedStatus: PeppolDeliveryStatus
|
||||
isTerminal: boolean
|
||||
detail: string | null
|
||||
occurredAt: string
|
||||
rawPayload: Record<string, unknown>
|
||||
eventSha256: string
|
||||
verificationMethod: string
|
||||
}
|
||||
|
||||
export interface PeppolDeliveryEvidence {
|
||||
provider: string
|
||||
evidenceType: string
|
||||
payload: Record<string, unknown>
|
||||
exactDocument: string | null
|
||||
exactDocumentSha256: string | null
|
||||
evidenceSha256: string
|
||||
retrievedAt: string
|
||||
}
|
||||
|
||||
export interface PeppolWebhookRequest {
|
||||
headers: Headers
|
||||
rawBody: Uint8Array
|
||||
}
|
||||
|
||||
export interface PeppolTransport {
|
||||
readonly provider: string
|
||||
lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup>
|
||||
submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt>
|
||||
verifyWebhook(request: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]>
|
||||
retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]>
|
||||
}
|
||||
|
||||
const transports = new Map<string, PeppolTransport>()
|
||||
|
||||
export function registerPeppolTransport(transport: PeppolTransport): () => void {
|
||||
const provider = transport.provider.trim().toLowerCase()
|
||||
if (!provider) throw new Error('Peppol transport provider is required')
|
||||
if (transports.has(provider)) {
|
||||
throw new Error(`Peppol transport already registered: ${provider}`)
|
||||
}
|
||||
|
||||
transports.set(provider, transport)
|
||||
return () => {
|
||||
if (transports.get(provider) === transport) transports.delete(provider)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPeppolTransport(provider: string): PeppolTransport | null {
|
||||
return transports.get(provider.trim().toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
export type PeppolTransportAvailability =
|
||||
| { available: true; provider: string }
|
||||
| {
|
||||
available: false
|
||||
provider: null
|
||||
reason: 'provider_selection_required' | 'provider_adapter_unavailable'
|
||||
}
|
||||
|
||||
export function getPeppolTransportAvailability(): PeppolTransportAvailability {
|
||||
const configuredProvider = process.env.PEPPOL_TRANSPORT_PROVIDER?.trim().toLowerCase()
|
||||
if (!configuredProvider) {
|
||||
return { available: false, provider: null, reason: 'provider_selection_required' }
|
||||
}
|
||||
|
||||
if (!transports.has(configuredProvider)) {
|
||||
return { available: false, provider: null, reason: 'provider_adapter_unavailable' }
|
||||
}
|
||||
|
||||
return { available: true, provider: configuredProvider }
|
||||
}
|
||||
@@ -840,6 +840,15 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// Delivery metadata proves which recipient received the archived PDF and
|
||||
// when, so it is räkenskapsinformation alongside the invoice itself.
|
||||
{ name: 'invoice_deliveries', file: 'invoice_deliveries.json', orderBy: 'created_at' },
|
||||
// Peppol archive evidence is split so the exact staged UBL, every verified
|
||||
// asynchronous event, and provider evidence stay independently auditable.
|
||||
{ name: 'peppol_deliveries', file: 'peppol_deliveries.json', orderBy: 'created_at' },
|
||||
{ name: 'peppol_delivery_events', file: 'peppol_delivery_events.json', orderBy: 'created_at' },
|
||||
{
|
||||
name: 'peppol_delivery_evidence',
|
||||
file: 'peppol_delivery_evidence.json',
|
||||
orderBy: 'created_at',
|
||||
},
|
||||
{ name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' },
|
||||
// Supplier invoicing
|
||||
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
|
||||
|
||||
@@ -3666,6 +3666,9 @@
|
||||
"copy_invoice": "Copy invoice",
|
||||
"download_pdf": "Download PDF",
|
||||
"download_peppol_xml": "Download Peppol XML",
|
||||
"prepare_peppol_delivery": "Prepare Peppol delivery",
|
||||
"send_via_peppol": "Send via Peppol",
|
||||
"peppol_provider_required": "A contracted and configured Peppol provider is required before Accounted can send.",
|
||||
"preview_pdf": "Preview",
|
||||
"viewer_disabled_tooltip": "You only have read-only access to this company",
|
||||
"customer_card_title": "Customer",
|
||||
@@ -3789,6 +3792,10 @@
|
||||
"peppol_downloaded_description": "The XML file passed Accounted's local checks. It has not been sent to Peppol.",
|
||||
"peppol_download_failed_title": "Could not create Peppol XML",
|
||||
"peppol_download_failed_description": "The invoice does not satisfy the Peppol profile supported by this export.",
|
||||
"peppol_prepared_title": "Peppol delivery prepared",
|
||||
"peppol_prepared_description": "An immutable XML copy was saved for traceability. The invoice has not been sent to Peppol.",
|
||||
"peppol_prepare_failed_title": "Could not prepare Peppol delivery",
|
||||
"peppol_prepare_failed_description": "The invoice could not be saved as a prepared Peppol delivery.",
|
||||
"pdf_rerender_downloaded_title": "Freshly generated PDF downloaded",
|
||||
"pdf_rerender_preview_title": "Showing a freshly generated PDF",
|
||||
"pdf_preview_blocked_title": "Could not open the preview",
|
||||
|
||||
@@ -3666,6 +3666,9 @@
|
||||
"copy_invoice": "Kopiera faktura",
|
||||
"download_pdf": "Ladda ner PDF",
|
||||
"download_peppol_xml": "Ladda ner Peppol XML",
|
||||
"prepare_peppol_delivery": "Förbered Peppol-leverans",
|
||||
"send_via_peppol": "Skicka via Peppol",
|
||||
"peppol_provider_required": "En avtalad och konfigurerad Peppol-operatör krävs innan Accounted kan skicka.",
|
||||
"preview_pdf": "Förhandsgranska",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"customer_card_title": "Kund",
|
||||
@@ -3789,6 +3792,10 @@
|
||||
"peppol_downloaded_description": "XML-filen har klarat Accounteds lokala kontroll. Den har inte skickats till Peppol.",
|
||||
"peppol_download_failed_title": "Kunde inte skapa Peppol XML",
|
||||
"peppol_download_failed_description": "Fakturan uppfyller inte den Peppol-profil som exporten stöder.",
|
||||
"peppol_prepared_title": "Peppol-leverans förberedd",
|
||||
"peppol_prepared_description": "En oföränderlig XML-kopia har sparats för spårbarhet. Fakturan har inte skickats till Peppol.",
|
||||
"peppol_prepare_failed_title": "Kunde inte förbereda Peppol-leveransen",
|
||||
"peppol_prepare_failed_description": "Fakturan kunde inte sparas som en förberedd Peppol-leverans.",
|
||||
"pdf_rerender_downloaded_title": "Nyskapad PDF nedladdad",
|
||||
"pdf_rerender_preview_title": "Nyskapad PDF visas",
|
||||
"pdf_preview_blocked_title": "Kunde inte öppna förhandsgranskningen",
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
-- Provider-neutral Peppol delivery staging and audit foundation.
|
||||
--
|
||||
-- This migration deliberately does not implement network delivery. A certified
|
||||
-- Access Point contract, participant onboarding, credentials, and verified
|
||||
-- webhook contract are still required. The tables and RPCs preserve the exact
|
||||
-- staged UBL document, a stable provider idempotency key, every verified event,
|
||||
-- and retrieved evidence without equating Corner 3 transport with buyer
|
||||
-- acceptance.
|
||||
|
||||
CREATE TABLE public.peppol_deliveries (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT,
|
||||
idempotency_key uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
recipient_scheme text NOT NULL,
|
||||
recipient_identifier text NOT NULL,
|
||||
customization_id text NOT NULL,
|
||||
profile_id text NOT NULL,
|
||||
filename text NOT NULL,
|
||||
xml_payload text NOT NULL,
|
||||
xml_sha256 text NOT NULL,
|
||||
provider text,
|
||||
provider_tenant_id text,
|
||||
provider_submission_id text,
|
||||
status text NOT NULL DEFAULT 'staged',
|
||||
status_at timestamptz NOT NULL DEFAULT now(),
|
||||
status_detail text,
|
||||
submitted_at timestamptz,
|
||||
terminal_at timestamptz,
|
||||
evidence_retrieved_at timestamptz,
|
||||
retention_expires_at date NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peppol_deliveries_recipient_scheme_format
|
||||
CHECK (recipient_scheme ~ '^[0-9]{4}$'),
|
||||
CONSTRAINT peppol_deliveries_recipient_identifier_format
|
||||
CHECK (recipient_identifier = btrim(recipient_identifier)
|
||||
AND length(recipient_identifier) BETWEEN 1 AND 128),
|
||||
CONSTRAINT peppol_deliveries_xml_sha256_format
|
||||
CHECK (xml_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT peppol_deliveries_payload_present
|
||||
CHECK (length(xml_payload) > 0 AND length(filename) BETWEEN 1 AND 255),
|
||||
CONSTRAINT peppol_deliveries_provider_shape
|
||||
CHECK (
|
||||
(provider IS NULL AND provider_tenant_id IS NULL AND provider_submission_id IS NULL)
|
||||
OR (provider IS NOT NULL AND length(btrim(provider)) BETWEEN 1 AND 64)
|
||||
),
|
||||
CONSTRAINT peppol_deliveries_status_check CHECK (status IN (
|
||||
'staged',
|
||||
'recipient_verified',
|
||||
'submitting',
|
||||
'retryable_failure',
|
||||
'submission_accepted',
|
||||
'transport_succeeded',
|
||||
'recipient_acknowledged',
|
||||
'business_accepted',
|
||||
'business_rejected',
|
||||
'no_route',
|
||||
'failed'
|
||||
)),
|
||||
CONSTRAINT peppol_deliveries_terminal_shape CHECK (
|
||||
terminal_at IS NULL
|
||||
OR status IN ('business_accepted', 'business_rejected', 'no_route', 'failed')
|
||||
),
|
||||
CONSTRAINT peppol_deliveries_submission_shape CHECK (
|
||||
submitted_at IS NULL OR provider IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_peppol_deliveries_company_idempotency
|
||||
ON public.peppol_deliveries (company_id, idempotency_key);
|
||||
CREATE UNIQUE INDEX idx_peppol_deliveries_staged_document
|
||||
ON public.peppol_deliveries (company_id, invoice_id, xml_sha256);
|
||||
CREATE UNIQUE INDEX idx_peppol_deliveries_provider_submission
|
||||
ON public.peppol_deliveries (provider, provider_submission_id)
|
||||
WHERE provider IS NOT NULL AND provider_submission_id IS NOT NULL;
|
||||
CREATE INDEX idx_peppol_deliveries_invoice_created
|
||||
ON public.peppol_deliveries (company_id, invoice_id, created_at DESC);
|
||||
|
||||
CREATE TABLE public.peppol_delivery_events (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||
delivery_id uuid NOT NULL REFERENCES public.peppol_deliveries(id) ON DELETE RESTRICT,
|
||||
source text NOT NULL,
|
||||
provider text,
|
||||
provider_event_id text,
|
||||
provider_event_code text NOT NULL,
|
||||
normalized_status text NOT NULL,
|
||||
is_terminal boolean NOT NULL DEFAULT false,
|
||||
detail text,
|
||||
raw_payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
event_sha256 text NOT NULL,
|
||||
verification_method text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peppol_delivery_events_source_check
|
||||
CHECK (source IN ('local', 'provider')),
|
||||
CONSTRAINT peppol_delivery_events_status_check CHECK (normalized_status IN (
|
||||
'staged',
|
||||
'recipient_verified',
|
||||
'submitting',
|
||||
'retryable_failure',
|
||||
'submission_accepted',
|
||||
'transport_succeeded',
|
||||
'recipient_acknowledged',
|
||||
'business_accepted',
|
||||
'business_rejected',
|
||||
'no_route',
|
||||
'failed'
|
||||
)),
|
||||
CONSTRAINT peppol_delivery_events_terminal_status CHECK (
|
||||
NOT is_terminal
|
||||
OR normalized_status IN ('business_accepted', 'business_rejected', 'no_route', 'failed')
|
||||
),
|
||||
CONSTRAINT peppol_delivery_events_raw_payload_object
|
||||
CHECK (jsonb_typeof(raw_payload) = 'object'),
|
||||
CONSTRAINT peppol_delivery_events_sha256_format
|
||||
CHECK (event_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT peppol_delivery_events_verification_present
|
||||
CHECK (length(btrim(verification_method)) BETWEEN 1 AND 64),
|
||||
CONSTRAINT peppol_delivery_events_provider_shape CHECK (
|
||||
(source = 'local' AND provider IS NULL)
|
||||
OR (source = 'provider' AND provider IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_peppol_delivery_events_fingerprint
|
||||
ON public.peppol_delivery_events (delivery_id, event_sha256);
|
||||
CREATE UNIQUE INDEX idx_peppol_delivery_events_provider_event
|
||||
ON public.peppol_delivery_events (provider, provider_event_id)
|
||||
WHERE provider IS NOT NULL AND provider_event_id IS NOT NULL;
|
||||
CREATE INDEX idx_peppol_delivery_events_delivery_received
|
||||
ON public.peppol_delivery_events (delivery_id, received_at);
|
||||
|
||||
CREATE TABLE public.peppol_delivery_evidence (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||
delivery_id uuid NOT NULL REFERENCES public.peppol_deliveries(id) ON DELETE RESTRICT,
|
||||
provider text NOT NULL,
|
||||
evidence_type text NOT NULL,
|
||||
evidence_payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
document_payload text,
|
||||
document_sha256 text,
|
||||
evidence_sha256 text NOT NULL,
|
||||
retrieved_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peppol_delivery_evidence_provider_present
|
||||
CHECK (length(btrim(provider)) BETWEEN 1 AND 64),
|
||||
CONSTRAINT peppol_delivery_evidence_type_present
|
||||
CHECK (length(btrim(evidence_type)) BETWEEN 1 AND 128),
|
||||
CONSTRAINT peppol_delivery_evidence_payload_object
|
||||
CHECK (jsonb_typeof(evidence_payload) = 'object'),
|
||||
CONSTRAINT peppol_delivery_evidence_sha256_format
|
||||
CHECK (evidence_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT peppol_delivery_evidence_document_sha256_format
|
||||
CHECK (document_sha256 IS NULL OR document_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT peppol_delivery_evidence_document_shape CHECK (
|
||||
(document_payload IS NULL AND document_sha256 IS NULL)
|
||||
OR (document_payload IS NOT NULL AND document_sha256 IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_peppol_delivery_evidence_fingerprint
|
||||
ON public.peppol_delivery_evidence (delivery_id, evidence_sha256);
|
||||
CREATE INDEX idx_peppol_delivery_evidence_delivery_retrieved
|
||||
ON public.peppol_delivery_evidence (delivery_id, retrieved_at);
|
||||
|
||||
ALTER TABLE public.peppol_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.peppol_delivery_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.peppol_delivery_evidence ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies express tenant ownership, but direct table grants stay revoked so
|
||||
-- the browser cannot read immutable XML, raw webhooks, or provider evidence.
|
||||
-- User-facing reads go through the minimized summary RPC below.
|
||||
CREATE POLICY "view own-company peppol deliveries"
|
||||
ON public.peppol_deliveries FOR SELECT
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "view own-company peppol delivery events"
|
||||
ON public.peppol_delivery_events FOR SELECT
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "view own-company peppol delivery evidence"
|
||||
ON public.peppol_delivery_evidence FOR SELECT
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
REVOKE ALL ON public.peppol_deliveries FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON public.peppol_delivery_events FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON public.peppol_delivery_evidence FROM PUBLIC, anon, authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.peppol_delivery_status_rank(p_status text)
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
SELECT CASE p_status
|
||||
WHEN 'staged' THEN 0
|
||||
WHEN 'recipient_verified' THEN 5
|
||||
WHEN 'submitting' THEN 10
|
||||
WHEN 'retryable_failure' THEN 15
|
||||
WHEN 'submission_accepted' THEN 20
|
||||
WHEN 'transport_succeeded' THEN 30
|
||||
WHEN 'recipient_acknowledged' THEN 40
|
||||
WHEN 'business_accepted' THEN 50
|
||||
WHEN 'business_rejected' THEN 50
|
||||
WHEN 'no_route' THEN 50
|
||||
WHEN 'failed' THEN 50
|
||||
ELSE -1
|
||||
END
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.peppol_delivery_status_rank(text)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Peppol delivery records are retained and cannot be deleted'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF current_setting('accounted.peppol_projection_update', true) IS DISTINCT FROM '1' THEN
|
||||
RAISE EXCEPTION 'Peppol delivery state may only change through its event RPC'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
|
||||
OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key
|
||||
OR NEW.recipient_scheme IS DISTINCT FROM OLD.recipient_scheme
|
||||
OR NEW.recipient_identifier IS DISTINCT FROM OLD.recipient_identifier
|
||||
OR NEW.customization_id IS DISTINCT FROM OLD.customization_id
|
||||
OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
|
||||
OR NEW.filename IS DISTINCT FROM OLD.filename
|
||||
OR NEW.xml_payload IS DISTINCT FROM OLD.xml_payload
|
||||
OR NEW.xml_sha256 IS DISTINCT FROM OLD.xml_sha256
|
||||
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol staged document and tenant identity are immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF OLD.provider IS NOT NULL AND NEW.provider IS DISTINCT FROM OLD.provider THEN
|
||||
RAISE EXCEPTION 'Peppol provider cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.provider_tenant_id IS NOT NULL
|
||||
AND NEW.provider_tenant_id IS DISTINCT FROM OLD.provider_tenant_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider tenant cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.provider_submission_id IS NOT NULL
|
||||
AND NEW.provider_submission_id IS DISTINCT FROM OLD.provider_submission_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider submission cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_delivery_immutability
|
||||
BEFORE UPDATE OR DELETE ON public.peppol_deliveries
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_immutability();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_append_only()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Peppol audit records are append-only'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_delivery_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON public.peppol_delivery_events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_append_only();
|
||||
CREATE TRIGGER enforce_peppol_delivery_evidence_append_only
|
||||
BEFORE UPDATE OR DELETE ON public.peppol_delivery_evidence
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_append_only();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.stage_peppol_delivery(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_recipient_scheme text,
|
||||
p_recipient_identifier text,
|
||||
p_customization_id text,
|
||||
p_profile_id text,
|
||||
p_filename text,
|
||||
p_xml_payload text,
|
||||
p_xml_sha256 text
|
||||
)
|
||||
RETURNS public.peppol_deliveries
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid := auth.uid();
|
||||
invoice_date date;
|
||||
invoice_owner uuid;
|
||||
retention_basis date;
|
||||
staged public.peppol_deliveries%ROWTYPE;
|
||||
BEGIN
|
||||
IF actor_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id
|
||||
AND member.user_id = actor_id
|
||||
AND member.role <> 'viewer'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'not authorized to stage Peppol delivery'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT invoice.invoice_date, invoice.user_id
|
||||
INTO invoice_date, invoice_owner
|
||||
FROM public.invoices AS invoice
|
||||
WHERE invoice.id = p_invoice_id
|
||||
AND invoice.company_id = p_company_id
|
||||
AND invoice.invoice_number IS NOT NULL
|
||||
AND invoice.status <> 'cancelled';
|
||||
|
||||
IF invoice_date IS NULL THEN
|
||||
RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT period.period_end
|
||||
INTO retention_basis
|
||||
FROM public.fiscal_periods AS period
|
||||
WHERE period.company_id = p_company_id
|
||||
AND invoice_date BETWEEN period.period_start AND period.period_end
|
||||
ORDER BY period.period_end DESC
|
||||
LIMIT 1;
|
||||
retention_basis := COALESCE(retention_basis, invoice_date);
|
||||
|
||||
SELECT * INTO staged
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
AND delivery.xml_sha256 = p_xml_sha256;
|
||||
IF FOUND THEN
|
||||
RETURN staged;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_deliveries (
|
||||
company_id, user_id, invoice_id,
|
||||
recipient_scheme, recipient_identifier, customization_id, profile_id,
|
||||
filename, xml_payload, xml_sha256, retention_expires_at
|
||||
) VALUES (
|
||||
p_company_id, actor_id, p_invoice_id,
|
||||
p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id,
|
||||
p_filename, p_xml_payload, lower(p_xml_sha256),
|
||||
(date_trunc('year', retention_basis)::date + interval '8 years')::date
|
||||
)
|
||||
RETURNING * INTO staged;
|
||||
|
||||
INSERT INTO public.peppol_delivery_events (
|
||||
company_id, delivery_id, source, provider_event_code, normalized_status,
|
||||
raw_payload, event_sha256, verification_method, occurred_at
|
||||
) VALUES (
|
||||
p_company_id, staged.id, 'local', 'staged', 'staged',
|
||||
jsonb_build_object(
|
||||
'invoice_id', p_invoice_id,
|
||||
'idempotency_key', staged.idempotency_key,
|
||||
'xml_sha256', staged.xml_sha256
|
||||
),
|
||||
staged.xml_sha256,
|
||||
'local',
|
||||
staged.created_at
|
||||
);
|
||||
|
||||
RETURN staged;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.stage_peppol_delivery(
|
||||
uuid, uuid, text, text, text, text, text, text, text
|
||||
) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.stage_peppol_delivery(
|
||||
uuid, uuid, text, text, text, text, text, text, text
|
||||
) TO authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_peppol_delivery_event(
|
||||
p_company_id uuid,
|
||||
p_idempotency_key uuid,
|
||||
p_provider text,
|
||||
p_provider_tenant_id text,
|
||||
p_provider_submission_id text,
|
||||
p_provider_event_id text,
|
||||
p_provider_event_code text,
|
||||
p_normalized_status text,
|
||||
p_is_terminal boolean,
|
||||
p_detail text,
|
||||
p_raw_payload jsonb,
|
||||
p_event_sha256 text,
|
||||
p_verification_method text,
|
||||
p_occurred_at timestamptz
|
||||
)
|
||||
RETURNS public.peppol_deliveries
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
target public.peppol_deliveries%ROWTYPE;
|
||||
inserted_count integer := 0;
|
||||
next_rank integer;
|
||||
current_rank integer;
|
||||
BEGIN
|
||||
IF COALESCE(current_setting('request.jwt.claim.role', true), '') <> 'service_role'
|
||||
AND COALESCE(current_setting('request.jwt.claims', true), '{}')::jsonb ->> 'role'
|
||||
IS DISTINCT FROM 'service_role'
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider events require service role'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_normalized_status NOT IN (
|
||||
'staged', 'recipient_verified', 'submitting', 'retryable_failure',
|
||||
'submission_accepted', 'transport_succeeded', 'recipient_acknowledged',
|
||||
'business_accepted', 'business_rejected', 'no_route', 'failed'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'unsupported normalized Peppol status'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO target
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.idempotency_key = p_idempotency_key
|
||||
FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Peppol delivery not found'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
IF target.provider IS NOT NULL AND target.provider IS DISTINCT FROM p_provider THEN
|
||||
RAISE EXCEPTION 'Peppol provider correlation mismatch'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF target.provider_tenant_id IS NOT NULL
|
||||
AND target.provider_tenant_id IS DISTINCT FROM p_provider_tenant_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider tenant correlation mismatch'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF target.provider_submission_id IS NOT NULL
|
||||
AND p_provider_submission_id IS NOT NULL
|
||||
AND target.provider_submission_id IS DISTINCT FROM p_provider_submission_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol submission correlation mismatch'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_delivery_events (
|
||||
company_id, delivery_id, source, provider, provider_event_id,
|
||||
provider_event_code, normalized_status, is_terminal, detail, raw_payload,
|
||||
event_sha256, verification_method, occurred_at
|
||||
) VALUES (
|
||||
p_company_id, target.id, 'provider', p_provider, p_provider_event_id,
|
||||
p_provider_event_code, p_normalized_status, p_is_terminal, p_detail,
|
||||
COALESCE(p_raw_payload, '{}'::jsonb), lower(p_event_sha256),
|
||||
p_verification_method, COALESCE(p_occurred_at, now())
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
GET DIAGNOSTICS inserted_count = ROW_COUNT;
|
||||
|
||||
IF inserted_count = 0 THEN
|
||||
RETURN target;
|
||||
END IF;
|
||||
|
||||
next_rank := public.peppol_delivery_status_rank(p_normalized_status);
|
||||
current_rank := public.peppol_delivery_status_rank(target.status);
|
||||
|
||||
PERFORM set_config('accounted.peppol_projection_update', '1', true);
|
||||
UPDATE public.peppol_deliveries AS delivery
|
||||
SET provider = COALESCE(delivery.provider, p_provider),
|
||||
provider_tenant_id = COALESCE(delivery.provider_tenant_id, p_provider_tenant_id),
|
||||
provider_submission_id = COALESCE(
|
||||
delivery.provider_submission_id,
|
||||
p_provider_submission_id
|
||||
),
|
||||
status = CASE
|
||||
WHEN delivery.terminal_at IS NOT NULL THEN delivery.status
|
||||
WHEN next_rank > current_rank THEN p_normalized_status
|
||||
WHEN delivery.status = 'retryable_failure'
|
||||
AND p_normalized_status IN ('submitting', 'submission_accepted')
|
||||
THEN p_normalized_status
|
||||
WHEN next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at
|
||||
THEN p_normalized_status
|
||||
ELSE delivery.status
|
||||
END,
|
||||
status_at = CASE
|
||||
WHEN delivery.terminal_at IS NOT NULL THEN delivery.status_at
|
||||
WHEN next_rank > current_rank
|
||||
OR (delivery.status = 'retryable_failure'
|
||||
AND p_normalized_status IN ('submitting', 'submission_accepted'))
|
||||
OR (next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at)
|
||||
THEN COALESCE(p_occurred_at, now())
|
||||
ELSE delivery.status_at
|
||||
END,
|
||||
status_detail = CASE
|
||||
WHEN delivery.terminal_at IS NULL AND (
|
||||
next_rank > current_rank
|
||||
OR (delivery.status = 'retryable_failure'
|
||||
AND p_normalized_status IN ('submitting', 'submission_accepted'))
|
||||
OR (next_rank = current_rank AND COALESCE(p_occurred_at, now()) > delivery.status_at)
|
||||
) THEN p_detail
|
||||
ELSE delivery.status_detail
|
||||
END,
|
||||
submitted_at = CASE
|
||||
WHEN p_normalized_status IN (
|
||||
'submission_accepted', 'transport_succeeded', 'recipient_acknowledged',
|
||||
'business_accepted', 'business_rejected'
|
||||
) THEN COALESCE(delivery.submitted_at, COALESCE(p_occurred_at, now()))
|
||||
ELSE delivery.submitted_at
|
||||
END,
|
||||
terminal_at = CASE
|
||||
WHEN delivery.terminal_at IS NULL AND p_is_terminal
|
||||
THEN COALESCE(p_occurred_at, now())
|
||||
ELSE delivery.terminal_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE delivery.id = target.id
|
||||
RETURNING * INTO target;
|
||||
|
||||
RETURN target;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.record_peppol_delivery_event(
|
||||
uuid, uuid, text, text, text, text, text, text, boolean, text, jsonb,
|
||||
text, text, timestamptz
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.record_peppol_delivery_event(
|
||||
uuid, uuid, text, text, text, text, text, text, boolean, text, jsonb,
|
||||
text, text, timestamptz
|
||||
) TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_peppol_delivery_evidence(
|
||||
p_company_id uuid,
|
||||
p_idempotency_key uuid,
|
||||
p_provider text,
|
||||
p_evidence_type text,
|
||||
p_evidence_payload jsonb,
|
||||
p_document_payload text,
|
||||
p_document_sha256 text,
|
||||
p_evidence_sha256 text,
|
||||
p_retrieved_at timestamptz
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
target public.peppol_deliveries%ROWTYPE;
|
||||
evidence_id uuid;
|
||||
BEGIN
|
||||
IF COALESCE(current_setting('request.jwt.claim.role', true), '') <> 'service_role'
|
||||
AND COALESCE(current_setting('request.jwt.claims', true), '{}')::jsonb ->> 'role'
|
||||
IS DISTINCT FROM 'service_role'
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol evidence writes require service role'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO target
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.idempotency_key = p_idempotency_key
|
||||
FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Peppol delivery not found'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
IF target.provider IS DISTINCT FROM p_provider THEN
|
||||
RAISE EXCEPTION 'Peppol evidence provider correlation mismatch'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_delivery_evidence (
|
||||
company_id, delivery_id, provider, evidence_type, evidence_payload,
|
||||
document_payload, document_sha256, evidence_sha256, retrieved_at
|
||||
) VALUES (
|
||||
p_company_id, target.id, p_provider, p_evidence_type,
|
||||
COALESCE(p_evidence_payload, '{}'::jsonb), p_document_payload,
|
||||
lower(p_document_sha256), lower(p_evidence_sha256),
|
||||
COALESCE(p_retrieved_at, now())
|
||||
)
|
||||
ON CONFLICT (delivery_id, evidence_sha256) DO NOTHING
|
||||
RETURNING id INTO evidence_id;
|
||||
|
||||
IF evidence_id IS NULL THEN
|
||||
SELECT evidence.id INTO evidence_id
|
||||
FROM public.peppol_delivery_evidence AS evidence
|
||||
WHERE evidence.delivery_id = target.id
|
||||
AND evidence.evidence_sha256 = lower(p_evidence_sha256);
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('accounted.peppol_projection_update', '1', true);
|
||||
UPDATE public.peppol_deliveries
|
||||
SET evidence_retrieved_at = GREATEST(
|
||||
COALESCE(evidence_retrieved_at, '-infinity'::timestamptz),
|
||||
COALESCE(p_retrieved_at, now())
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE id = target.id;
|
||||
|
||||
RETURN evidence_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.record_peppol_delivery_evidence(
|
||||
uuid, uuid, text, text, jsonb, text, text, text, timestamptz
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.record_peppol_delivery_evidence(
|
||||
uuid, uuid, text, text, jsonb, text, text, text, timestamptz
|
||||
) TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.list_peppol_delivery_summaries(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
idempotency_key uuid,
|
||||
recipient_scheme text,
|
||||
recipient_identifier text,
|
||||
xml_sha256 text,
|
||||
provider text,
|
||||
provider_submission_id text,
|
||||
status text,
|
||||
status_at timestamptz,
|
||||
status_detail text,
|
||||
submitted_at timestamptz,
|
||||
terminal_at timestamptz,
|
||||
evidence_retrieved_at timestamptz,
|
||||
created_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id
|
||||
AND member.user_id = auth.uid()
|
||||
) THEN
|
||||
RAISE EXCEPTION 'not authorized to list Peppol delivery summaries'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.idempotency_key,
|
||||
delivery.recipient_scheme,
|
||||
delivery.recipient_identifier,
|
||||
delivery.xml_sha256,
|
||||
delivery.provider,
|
||||
delivery.provider_submission_id,
|
||||
delivery.status,
|
||||
delivery.status_at,
|
||||
delivery.status_detail,
|
||||
delivery.submitted_at,
|
||||
delivery.terminal_at,
|
||||
delivery.evidence_retrieved_at,
|
||||
delivery.created_at
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
ORDER BY delivery.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.list_peppol_delivery_summaries(uuid, uuid)
|
||||
FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.list_peppol_delivery_summaries(uuid, uuid)
|
||||
TO authenticated;
|
||||
|
||||
COMMENT ON TABLE public.peppol_deliveries IS
|
||||
'Immutable staged Peppol BIS Billing documents plus a projection of verified delivery events. Staged does not mean sent; transport_succeeded does not mean buyer acceptance.';
|
||||
COMMENT ON TABLE public.peppol_delivery_events IS
|
||||
'Append-only normalized and raw verified Peppol provider events. All events remain evidence even when they arrive out of order and do not change the delivery projection.';
|
||||
COMMENT ON TABLE public.peppol_delivery_evidence IS
|
||||
'Append-only Access Point evidence snapshots, optionally including the exact transmitted document returned by the provider.';
|
||||
COMMENT ON COLUMN public.peppol_deliveries.idempotency_key IS
|
||||
'Stable caller-supplied key for a future provider submit call. Reused for retries of this exact staged XML.';
|
||||
COMMENT ON COLUMN public.peppol_deliveries.xml_payload IS
|
||||
'Exact UBL XML staged for submission. Immutable and excluded from user-facing summary RPCs.';
|
||||
COMMENT ON COLUMN public.peppol_deliveries.status IS
|
||||
'Latest monotonic normalized projection. Raw events remain authoritative audit evidence; transport_succeeded is only Corner 3 transport.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Hardening found by staging advisors and concurrency review.
|
||||
|
||||
CREATE INDEX idx_peppol_deliveries_invoice_id
|
||||
ON public.peppol_deliveries (invoice_id);
|
||||
CREATE INDEX idx_peppol_deliveries_user_id
|
||||
ON public.peppol_deliveries (user_id);
|
||||
CREATE INDEX idx_peppol_delivery_events_company_id
|
||||
ON public.peppol_delivery_events (company_id);
|
||||
CREATE INDEX idx_peppol_delivery_evidence_company_id
|
||||
ON public.peppol_delivery_evidence (company_id);
|
||||
|
||||
DROP POLICY "view own-company peppol deliveries" ON public.peppol_deliveries;
|
||||
CREATE POLICY "view own-company peppol deliveries"
|
||||
ON public.peppol_deliveries FOR SELECT TO authenticated
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
DROP POLICY "view own-company peppol delivery events" ON public.peppol_delivery_events;
|
||||
CREATE POLICY "view own-company peppol delivery events"
|
||||
ON public.peppol_delivery_events FOR SELECT TO authenticated
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
DROP POLICY "view own-company peppol delivery evidence" ON public.peppol_delivery_evidence;
|
||||
CREATE POLICY "view own-company peppol delivery evidence"
|
||||
ON public.peppol_delivery_evidence FOR SELECT TO authenticated
|
||||
USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Peppol delivery records are retained and cannot be deleted'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF current_setting('accounted.peppol_projection_update', true) IS DISTINCT FROM '1' THEN
|
||||
RAISE EXCEPTION 'Peppol delivery state may only change through its event RPC'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
|
||||
OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key
|
||||
OR NEW.recipient_scheme IS DISTINCT FROM OLD.recipient_scheme
|
||||
OR NEW.recipient_identifier IS DISTINCT FROM OLD.recipient_identifier
|
||||
OR NEW.customization_id IS DISTINCT FROM OLD.customization_id
|
||||
OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
|
||||
OR NEW.filename IS DISTINCT FROM OLD.filename
|
||||
OR NEW.xml_payload IS DISTINCT FROM OLD.xml_payload
|
||||
OR NEW.xml_sha256 IS DISTINCT FROM OLD.xml_sha256
|
||||
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol staged document and tenant identity are immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF OLD.provider IS NOT NULL AND NEW.provider IS DISTINCT FROM OLD.provider THEN
|
||||
RAISE EXCEPTION 'Peppol provider cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.provider_tenant_id IS NOT NULL
|
||||
AND NEW.provider_tenant_id IS DISTINCT FROM OLD.provider_tenant_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider tenant cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.provider_submission_id IS NOT NULL
|
||||
AND NEW.provider_submission_id IS DISTINCT FROM OLD.provider_submission_id
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol provider submission cannot change on an existing delivery'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_append_only()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Peppol audit records are append-only'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_immutability()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.enforce_peppol_append_only()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.stage_peppol_delivery(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_recipient_scheme text,
|
||||
p_recipient_identifier text,
|
||||
p_customization_id text,
|
||||
p_profile_id text,
|
||||
p_filename text,
|
||||
p_xml_payload text,
|
||||
p_xml_sha256 text
|
||||
)
|
||||
RETURNS public.peppol_deliveries
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid := auth.uid();
|
||||
invoice_date date;
|
||||
retention_basis date;
|
||||
staged public.peppol_deliveries%ROWTYPE;
|
||||
BEGIN
|
||||
IF actor_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id
|
||||
AND member.user_id = actor_id
|
||||
AND member.role <> 'viewer'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'not authorized to stage Peppol delivery'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT invoice.invoice_date
|
||||
INTO invoice_date
|
||||
FROM public.invoices AS invoice
|
||||
WHERE invoice.id = p_invoice_id
|
||||
AND invoice.company_id = p_company_id
|
||||
AND invoice.invoice_number IS NOT NULL
|
||||
AND invoice.status <> 'cancelled';
|
||||
|
||||
IF invoice_date IS NULL THEN
|
||||
RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT period.period_end
|
||||
INTO retention_basis
|
||||
FROM public.fiscal_periods AS period
|
||||
WHERE period.company_id = p_company_id
|
||||
AND invoice_date BETWEEN period.period_start AND period.period_end
|
||||
ORDER BY period.period_end DESC
|
||||
LIMIT 1;
|
||||
retention_basis := COALESCE(retention_basis, invoice_date);
|
||||
|
||||
INSERT INTO public.peppol_deliveries (
|
||||
company_id, user_id, invoice_id,
|
||||
recipient_scheme, recipient_identifier, customization_id, profile_id,
|
||||
filename, xml_payload, xml_sha256, retention_expires_at
|
||||
) VALUES (
|
||||
p_company_id, actor_id, p_invoice_id,
|
||||
p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id,
|
||||
p_filename, p_xml_payload, lower(p_xml_sha256),
|
||||
(date_trunc('year', retention_basis)::date + interval '8 years')::date
|
||||
)
|
||||
ON CONFLICT (company_id, invoice_id, xml_sha256) DO NOTHING
|
||||
RETURNING * INTO staged;
|
||||
|
||||
IF staged.id IS NULL THEN
|
||||
SELECT * INTO STRICT staged
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
AND delivery.xml_sha256 = lower(p_xml_sha256);
|
||||
RETURN staged;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_delivery_events (
|
||||
company_id, delivery_id, source, provider_event_code, normalized_status,
|
||||
raw_payload, event_sha256, verification_method, occurred_at
|
||||
) VALUES (
|
||||
p_company_id, staged.id, 'local', 'staged', 'staged',
|
||||
jsonb_build_object(
|
||||
'invoice_id', p_invoice_id,
|
||||
'idempotency_key', staged.idempotency_key,
|
||||
'xml_sha256', staged.xml_sha256
|
||||
),
|
||||
staged.xml_sha256,
|
||||
'local',
|
||||
staged.created_at
|
||||
);
|
||||
|
||||
RETURN staged;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Ensure immutable Peppol documents are cryptographically bound to their
|
||||
-- stored SHA-256 values even when an RPC is called outside the application.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_payload_hash()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, public, extensions
|
||||
AS $$
|
||||
BEGIN
|
||||
IF encode(extensions.digest(NEW.xml_payload, 'sha256'), 'hex')
|
||||
IS DISTINCT FROM NEW.xml_sha256
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol XML SHA-256 does not match the staged payload'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_delivery_payload_hash
|
||||
BEFORE INSERT ON public.peppol_deliveries
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_payload_hash();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_evidence_document_hash()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, public, extensions
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.document_payload IS NOT NULL
|
||||
AND encode(extensions.digest(NEW.document_payload, 'sha256'), 'hex')
|
||||
IS DISTINCT FROM NEW.document_sha256
|
||||
THEN
|
||||
RAISE EXCEPTION 'Peppol evidence document SHA-256 does not match the payload'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_evidence_document_hash
|
||||
BEFORE INSERT ON public.peppol_delivery_evidence
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_evidence_document_hash();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_payload_hash()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.enforce_peppol_evidence_document_hash()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- A Peppol archive record must inherit the legally corrected BFL retention
|
||||
-- date from the fiscal period containing the invoice. Refuse staging when the
|
||||
-- period is missing instead of guessing from the invoice date.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_peppol_delivery_retention_basis()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
expected_retention date;
|
||||
BEGIN
|
||||
SELECT period.retention_expires_at
|
||||
INTO expected_retention
|
||||
FROM public.invoices AS invoice
|
||||
JOIN public.fiscal_periods AS period
|
||||
ON period.company_id = invoice.company_id
|
||||
AND invoice.invoice_date BETWEEN period.period_start AND period.period_end
|
||||
WHERE invoice.id = NEW.invoice_id
|
||||
AND invoice.company_id = NEW.company_id
|
||||
ORDER BY period.period_end DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF expected_retention IS NULL THEN
|
||||
RAISE EXCEPTION 'Peppol delivery requires a fiscal period retention basis'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
IF NEW.retention_expires_at IS DISTINCT FROM expected_retention THEN
|
||||
RAISE EXCEPTION 'Peppol retention date must match the invoice fiscal period'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_peppol_delivery_retention_basis
|
||||
BEFORE INSERT ON public.peppol_deliveries
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_peppol_delivery_retention_basis();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.enforce_peppol_delivery_retention_basis()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Keep the legally corrected fiscal-period retention date as the single source
|
||||
-- of truth for staged Peppol records. Missing periods remain a hard failure.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.stage_peppol_delivery(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_recipient_scheme text,
|
||||
p_recipient_identifier text,
|
||||
p_customization_id text,
|
||||
p_profile_id text,
|
||||
p_filename text,
|
||||
p_xml_payload text,
|
||||
p_xml_sha256 text
|
||||
)
|
||||
RETURNS public.peppol_deliveries
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid := auth.uid();
|
||||
invoice_date date;
|
||||
retention_expiry date;
|
||||
staged public.peppol_deliveries%ROWTYPE;
|
||||
BEGIN
|
||||
IF actor_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id
|
||||
AND member.user_id = actor_id
|
||||
AND member.role <> 'viewer'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'not authorized to stage Peppol delivery'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT invoice.invoice_date
|
||||
INTO invoice_date
|
||||
FROM public.invoices AS invoice
|
||||
WHERE invoice.id = p_invoice_id
|
||||
AND invoice.company_id = p_company_id
|
||||
AND invoice.invoice_number IS NOT NULL
|
||||
AND invoice.status <> 'cancelled';
|
||||
|
||||
IF invoice_date IS NULL THEN
|
||||
RAISE EXCEPTION 'invoice not found or not eligible for Peppol staging'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT period.retention_expires_at
|
||||
INTO retention_expiry
|
||||
FROM public.fiscal_periods AS period
|
||||
WHERE period.company_id = p_company_id
|
||||
AND invoice_date BETWEEN period.period_start AND period.period_end
|
||||
ORDER BY period.period_end DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF retention_expiry IS NULL THEN
|
||||
RAISE EXCEPTION 'Peppol delivery requires a fiscal period retention basis'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_deliveries (
|
||||
company_id, user_id, invoice_id,
|
||||
recipient_scheme, recipient_identifier, customization_id, profile_id,
|
||||
filename, xml_payload, xml_sha256, retention_expires_at
|
||||
) VALUES (
|
||||
p_company_id, actor_id, p_invoice_id,
|
||||
p_recipient_scheme, p_recipient_identifier, p_customization_id, p_profile_id,
|
||||
p_filename, p_xml_payload, lower(p_xml_sha256), retention_expiry
|
||||
)
|
||||
ON CONFLICT (company_id, invoice_id, xml_sha256) DO NOTHING
|
||||
RETURNING * INTO staged;
|
||||
|
||||
IF staged.id IS NULL THEN
|
||||
SELECT * INTO STRICT staged
|
||||
FROM public.peppol_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
AND delivery.xml_sha256 = lower(p_xml_sha256);
|
||||
RETURN staged;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.peppol_delivery_events (
|
||||
company_id, delivery_id, source, provider_event_code, normalized_status,
|
||||
raw_payload, event_sha256, verification_method, occurred_at
|
||||
) VALUES (
|
||||
p_company_id, staged.id, 'local', 'staged', 'staged',
|
||||
jsonb_build_object(
|
||||
'invoice_id', p_invoice_id,
|
||||
'idempotency_key', staged.idempotency_key,
|
||||
'xml_sha256', staged.xml_sha256
|
||||
),
|
||||
staged.xml_sha256,
|
||||
'local',
|
||||
staged.created_at
|
||||
);
|
||||
|
||||
RETURN staged;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,307 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, runAsServiceRole, withUserContext } from './setup'
|
||||
import { insertAuthUser, insertCompanyMember, seedCompany } from './fixtures'
|
||||
|
||||
const XML = '<Invoice><cbc:ID>F-2026-42</cbc:ID></Invoice>'
|
||||
const XML_SHA = createHash('sha256').update(XML).digest('hex')
|
||||
|
||||
async function insertInvoice(userId: string, companyId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, invoice_number, invoice_date, due_date,
|
||||
status, currency, total)
|
||||
VALUES ($1, $2, $3, 'F-2026-42', '2026-08-13', '2026-09-12',
|
||||
'sent', 'SEK', 125)`,
|
||||
[id, userId, companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
const STAGE_SQL = `
|
||||
SELECT (public.stage_peppol_delivery(
|
||||
$1, $2, '0007', '5566778899',
|
||||
'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
|
||||
'peppol-invoice-F-2026-42.xml', $3, $4
|
||||
)).*`
|
||||
|
||||
async function seedStagedDelivery(): Promise<{
|
||||
companyId: string
|
||||
userId: string
|
||||
invoiceId: string
|
||||
deliveryId: string
|
||||
idempotencyKey: string
|
||||
}> {
|
||||
const seeded = await seedCompany()
|
||||
const invoiceId = await insertInvoice(seeded.userId, seeded.companyId)
|
||||
const deliveryId = randomUUID()
|
||||
const idempotencyKey = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.peppol_deliveries (
|
||||
id, company_id, user_id, invoice_id, idempotency_key,
|
||||
recipient_scheme, recipient_identifier, customization_id, profile_id,
|
||||
filename, xml_payload, xml_sha256, retention_expires_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, '0007', '5566778899',
|
||||
'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
|
||||
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
|
||||
'peppol-invoice-F-2026-42.xml', $6, $7, '2034-01-01'
|
||||
)`,
|
||||
[
|
||||
deliveryId,
|
||||
seeded.companyId,
|
||||
seeded.userId,
|
||||
invoiceId,
|
||||
idempotencyKey,
|
||||
XML,
|
||||
XML_SHA,
|
||||
],
|
||||
)
|
||||
return { ...seeded, invoiceId, deliveryId, idempotencyKey }
|
||||
}
|
||||
|
||||
describe('stage_peppol_delivery', () => {
|
||||
it('stores one immutable exact-document snapshot and is idempotent for the same XML', async () => {
|
||||
const seeded = await seedCompany()
|
||||
const invoiceId = await insertInvoice(seeded.userId, seeded.companyId)
|
||||
|
||||
await withUserContext(seeded.userId, async (client) => {
|
||||
const first = await client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA])
|
||||
const second = await client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA])
|
||||
|
||||
expect(second.rows[0].id).toBe(first.rows[0].id)
|
||||
expect(first.rows[0]).toMatchObject({
|
||||
company_id: seeded.companyId,
|
||||
invoice_id: invoiceId,
|
||||
recipient_scheme: '0007',
|
||||
recipient_identifier: '5566778899',
|
||||
xml_payload: XML,
|
||||
xml_sha256: XML_SHA,
|
||||
status: 'staged',
|
||||
})
|
||||
expect(first.rows[0].idempotency_key).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(first.rows[0].retention_expires_at.toISOString().slice(0, 10)).toBe('2034-01-01')
|
||||
|
||||
await expect(client.query(
|
||||
`SELECT raw_payload FROM public.peppol_delivery_events WHERE delivery_id = $1`,
|
||||
[first.rows[0].id],
|
||||
)).rejects.toThrow(/permission denied/)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects viewers and cross-company invoices', async () => {
|
||||
const seeded = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
const invoiceId = await insertInvoice(seeded.userId, seeded.companyId)
|
||||
const viewerId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId: seeded.companyId, userId: viewerId, role: 'viewer' })
|
||||
|
||||
await expect(withUserContext(viewerId, (client) =>
|
||||
client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]),
|
||||
)).rejects.toThrow(/not authorized/)
|
||||
|
||||
await expect(withUserContext(other.userId, (client) =>
|
||||
client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]),
|
||||
)).rejects.toThrow(/not authorized/)
|
||||
})
|
||||
|
||||
it('rejects a caller-supplied SHA-256 that does not match the XML', async () => {
|
||||
const seeded = await seedCompany()
|
||||
const invoiceId = await insertInvoice(seeded.userId, seeded.companyId)
|
||||
|
||||
await expect(withUserContext(seeded.userId, (client) =>
|
||||
client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, 'b'.repeat(64)]),
|
||||
)).rejects.toThrow(/does not match the staged payload/)
|
||||
})
|
||||
|
||||
it('refuses to guess a retention date when the invoice has no fiscal period', async () => {
|
||||
const seeded = await seedCompany()
|
||||
await getPool().query('DELETE FROM public.fiscal_periods WHERE id = $1', [
|
||||
seeded.fiscalPeriodId,
|
||||
])
|
||||
const invoiceId = await insertInvoice(seeded.userId, seeded.companyId)
|
||||
|
||||
await expect(withUserContext(seeded.userId, (client) =>
|
||||
client.query(STAGE_SQL, [seeded.companyId, invoiceId, XML, XML_SHA]),
|
||||
)).rejects.toThrow(/requires a fiscal period retention basis/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Peppol delivery audit lifecycle', () => {
|
||||
it('keeps events append-only and does not let late events regress a terminal projection', async () => {
|
||||
const seeded = await seedStagedDelivery()
|
||||
const eventSql = `SELECT (public.record_peppol_delivery_event(
|
||||
$1, $2, 'storecove', 'tenant-42', $3, $4, $5, $6, $7, $8,
|
||||
$9::jsonb, $10, 'hmac-sha256', $11::timestamptz
|
||||
)).*`
|
||||
|
||||
await runAsServiceRole(async (client) => {
|
||||
await client.query(eventSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-1',
|
||||
'succeeded', 'transport_succeeded', false, 'Delivered to Corner 3',
|
||||
JSON.stringify({ event: 'succeeded' }), '1'.repeat(64), '2026-08-13T16:01:00Z',
|
||||
])
|
||||
await client.query(eventSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-2',
|
||||
'temporary_error', 'retryable_failure', false, 'Late retry notice',
|
||||
JSON.stringify({ event: 'temporary_error' }), '2'.repeat(64), '2026-08-13T16:00:00Z',
|
||||
])
|
||||
await client.query(eventSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-3',
|
||||
'accepted', 'business_accepted', true, 'Buyer accepted',
|
||||
JSON.stringify({ event: 'accepted' }), '3'.repeat(64), '2026-08-13T16:02:00Z',
|
||||
])
|
||||
await client.query(eventSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-4',
|
||||
'failed', 'failed', true, 'Late contradictory event',
|
||||
JSON.stringify({ event: 'failed' }), '4'.repeat(64), '2026-08-13T16:03:00Z',
|
||||
])
|
||||
// Provider retry of event-3: same fingerprint and event id is a no-op.
|
||||
await client.query(eventSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, 'submission-guid', 'event-3',
|
||||
'accepted', 'business_accepted', true, 'Buyer accepted',
|
||||
JSON.stringify({ event: 'accepted' }), '3'.repeat(64), '2026-08-13T16:02:00Z',
|
||||
])
|
||||
})
|
||||
|
||||
const delivery = await getPool().query(
|
||||
`SELECT provider, provider_tenant_id, provider_submission_id, status,
|
||||
terminal_at, status_detail
|
||||
FROM public.peppol_deliveries WHERE id = $1`,
|
||||
[seeded.deliveryId],
|
||||
)
|
||||
expect(delivery.rows[0]).toMatchObject({
|
||||
provider: 'storecove',
|
||||
provider_tenant_id: 'tenant-42',
|
||||
provider_submission_id: 'submission-guid',
|
||||
status: 'business_accepted',
|
||||
status_detail: 'Buyer accepted',
|
||||
})
|
||||
expect(delivery.rows[0].terminal_at).not.toBeNull()
|
||||
|
||||
const events = await getPool().query(
|
||||
`SELECT provider_event_id FROM public.peppol_delivery_events
|
||||
WHERE delivery_id = $1 ORDER BY occurred_at`,
|
||||
[seeded.deliveryId],
|
||||
)
|
||||
expect(events.rows.map((row) => row.provider_event_id)).toEqual([
|
||||
'event-2', 'event-1', 'event-3', 'event-4',
|
||||
])
|
||||
|
||||
await expect(getPool().query(
|
||||
`UPDATE public.peppol_delivery_events SET detail = 'changed' WHERE delivery_id = $1`,
|
||||
[seeded.deliveryId],
|
||||
)).rejects.toThrow(/append-only/)
|
||||
await expect(getPool().query(
|
||||
`DELETE FROM public.peppol_deliveries WHERE id = $1`,
|
||||
[seeded.deliveryId],
|
||||
)).rejects.toThrow(/cannot be deleted/)
|
||||
})
|
||||
|
||||
it('stores provider evidence idempotently and keeps its exact document immutable', async () => {
|
||||
const seeded = await seedStagedDelivery()
|
||||
await runAsServiceRole(async (client) => {
|
||||
await client.query(
|
||||
`SELECT public.record_peppol_delivery_event(
|
||||
$1, $2, 'storecove', 'tenant-42', $3, $4,
|
||||
'submission_accepted', 'submission_accepted', false, NULL,
|
||||
'{"event":"submission_accepted"}'::jsonb, $5, 'hmac-sha256', now()
|
||||
)`,
|
||||
[
|
||||
seeded.companyId,
|
||||
seeded.idempotencyKey,
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
'5'.repeat(64),
|
||||
],
|
||||
)
|
||||
const evidenceSql = `SELECT public.record_peppol_delivery_evidence(
|
||||
$1, $2, 'storecove', 'access_point_evidence', '{"receipt":"ok"}'::jsonb,
|
||||
$3, $4, $5, '2026-08-13T16:05:00Z'
|
||||
) AS id`
|
||||
const first = await client.query(evidenceSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, XML, XML_SHA, '6'.repeat(64),
|
||||
])
|
||||
const second = await client.query(evidenceSql, [
|
||||
seeded.companyId, seeded.idempotencyKey, XML, XML_SHA, '6'.repeat(64),
|
||||
])
|
||||
expect(second.rows[0].id).toBe(first.rows[0].id)
|
||||
})
|
||||
|
||||
const evidence = await getPool().query(
|
||||
`SELECT document_payload, document_sha256
|
||||
FROM public.peppol_delivery_evidence WHERE delivery_id = $1`,
|
||||
[seeded.deliveryId],
|
||||
)
|
||||
expect(evidence.rows).toEqual([{ document_payload: XML, document_sha256: XML_SHA }])
|
||||
|
||||
await expect(getPool().query(
|
||||
`UPDATE public.peppol_delivery_evidence SET document_payload = 'changed'
|
||||
WHERE delivery_id = $1`,
|
||||
[seeded.deliveryId],
|
||||
)).rejects.toThrow(/append-only/)
|
||||
})
|
||||
|
||||
it('rejects provider evidence whose exact-document hash is inconsistent', async () => {
|
||||
const seeded = await seedStagedDelivery()
|
||||
await runAsServiceRole(async (client) => {
|
||||
await client.query(
|
||||
`SELECT public.record_peppol_delivery_event(
|
||||
$1, $2, 'storecove', 'tenant-42', $3, $4,
|
||||
'submission_accepted', 'submission_accepted', false, NULL,
|
||||
'{"event":"submission_accepted"}'::jsonb, $5, 'hmac-sha256', now()
|
||||
)`,
|
||||
[
|
||||
seeded.companyId,
|
||||
seeded.idempotencyKey,
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
'7'.repeat(64),
|
||||
],
|
||||
)
|
||||
|
||||
await expect(client.query(
|
||||
`SELECT public.record_peppol_delivery_evidence(
|
||||
$1, $2, 'storecove', 'access_point_evidence', '{}'::jsonb,
|
||||
$3, $4, $5, now()
|
||||
)`,
|
||||
[seeded.companyId, seeded.idempotencyKey, XML, 'b'.repeat(64), '8'.repeat(64)],
|
||||
)).rejects.toThrow(/does not match the payload/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Peppol delivery RPC privileges', () => {
|
||||
it('keeps raw tables closed and provider writes service-role only', async () => {
|
||||
const privileges = await getPool().query<{
|
||||
authenticated_table_select: boolean
|
||||
anon_table_select: boolean
|
||||
authenticated_event_exec: boolean
|
||||
service_event_exec: boolean
|
||||
}>(`
|
||||
SELECT
|
||||
has_table_privilege('authenticated', 'public.peppol_deliveries', 'SELECT')
|
||||
AS authenticated_table_select,
|
||||
has_table_privilege('anon', 'public.peppol_deliveries', 'SELECT')
|
||||
AS anon_table_select,
|
||||
has_function_privilege(
|
||||
'authenticated',
|
||||
'public.record_peppol_delivery_event(uuid,uuid,text,text,text,text,text,text,boolean,text,jsonb,text,text,timestamptz)',
|
||||
'EXECUTE'
|
||||
) AS authenticated_event_exec,
|
||||
has_function_privilege(
|
||||
'service_role',
|
||||
'public.record_peppol_delivery_event(uuid,uuid,text,text,text,text,text,text,boolean,text,jsonb,text,text,timestamptz)',
|
||||
'EXECUTE'
|
||||
) AS service_event_exec
|
||||
`)
|
||||
expect(privileges.rows[0]).toEqual({
|
||||
authenticated_table_select: false,
|
||||
anon_table_select: false,
|
||||
authenticated_event_exec: false,
|
||||
service_event_exec: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user