feat(compliance): InvoiceRowsCompleted behandlingshistorik event for migrated invoice rows (#2312) (#2357)

Every migrated sales invoice whose rows complete_invoice_rows writes, from
the migration wizard or the hourly row-completion pass, now leaves one
InvoiceRowsCompleted row in processing_history on a new Invoice aggregate:
the writer, the provider, the consent, the row count, and the header VAT
split before and after when the pass rewrote it (BFL 5 kap 11 §, BFNAR
2013:2 p. 9.16). One run shares one correlation id.

lib/invoices/complete-invoice-rows.ts is the one TypeScript call site for
the RPC and the one emitter: it appends only on wrote = true, records
nothing for already_filled or failed, and keeps the append best-effort
(logged, eventId null) like every other processing_history writer. The
wizard runs on the user's session client, so MigrationOptions takes a lazy
createHistoryClient for the service role. Invoice numbers stay out of the
payload (the personnummer guard would drop ten-digit ones).

Migration 20260906210100 widens the aggregate_type CHECK with Invoice and
registers the event type; pg test covers the catalog row, the aggregate,
and that the CHECK still refuses unknown aggregates.


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:04:53 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent ebbe50c0f3
commit 6906bc4aa2
14 changed files with 730 additions and 65 deletions
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { PROCESSING_EVENT_TYPES } from '@/lib/processing-history/append'
import {
completeInvoiceRows,
INVOICE_ROWS_COMPLETED_EVENT,
type CompleteInvoiceRowsTrail,
} from '../complete-invoice-rows'
/**
* The one call site for complete_invoice_rows and the one emitter of the
* InvoiceRowsCompleted behandlingshistorik event (#2312). The append runs for
* real against a spy client: what lands in processing_history is the row
* shape and the PII guard of appendProcessingHistoryWithClient, not a mock's
* idea of it. The RPC is a spy.
*/
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
const COMPANY = '11111111-1111-4111-8111-111111111111'
const INVOICE = '22222222-2222-4222-8222-222222222222'
const CONSENT = '33333333-3333-4333-8333-333333333333'
const RUN = '44444444-4444-4444-8444-444444444444'
const ROWS = [
{ sort_order: 1, description: 'Konsulttid', quantity: 8, unit: 'h', unit_price: 100, line_total: 800, vat_rate: 25, vat_amount: 200, line_type: 'product' },
{ sort_order: 2, description: 'Resa', quantity: 1, unit: 'st', unit_price: 200, line_total: 200, vat_rate: 25, vat_amount: 50, line_type: 'product' },
]
const HEADER = {
subtotal: 1000,
subtotal_sek: 1000,
vat_amount: 250,
vat_amount_sek: 250,
vat_rate: 25,
vat_treatment: 'standard_25',
}
/** The pre-#1745 shape: 25 % label beside 0 kr VAT and subtotal = total. */
const BEFORE = { subtotal: 1250, vat_amount: 0, vat_rate: 25, vat_treatment: 'standard_25' }
const trail: CompleteInvoiceRowsTrail = {
source: 'complete-invoice-lines',
provider: 'fortnox',
consentId: CONSENT,
correlationId: RUN,
actor: { type: 'cron', id: 'complete-invoice-lines' },
}
function rpcClient(reply: { data?: unknown; error?: { message: string } | null }) {
const rpc = vi.fn().mockResolvedValue({ data: reply.data ?? null, error: reply.error ?? null })
return { client: { rpc } as never, rpc }
}
function historyClient(error: { message: string } | null = null) {
const insert = vi.fn().mockResolvedValue({ error })
const from = vi.fn().mockReturnValue({ insert })
return { client: { from } as never, from, insert }
}
const wrote = (rows: number, headerUpdated: boolean) => ({
data: { ok: true, wrote: true, rows, header_updated: headerUpdated },
})
describe('completeInvoiceRows', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('names an event type the catalog registers', () => {
// The union already refuses an unregistered literal at compile time;
// this pins the runtime list the pg test reads against the migration.
expect(PROCESSING_EVENT_TYPES).toContain(INVOICE_ROWS_COMPLETED_EVENT)
})
it('writes through the RPC and appends one InvoiceRowsCompleted on the invoice, with the split before and after', async () => {
const { client: supabase, rpc } = rpcClient(wrote(2, true))
const history = historyClient()
const result = await completeInvoiceRows(supabase, {
companyId: COMPANY,
invoiceId: INVOICE,
rows: ROWS,
header: HEADER,
headerBefore: BEFORE,
trail,
historyClient: history.client,
})
expect(rpc).toHaveBeenCalledTimes(1)
expect(rpc).toHaveBeenCalledWith('complete_invoice_rows', {
p_company_id: COMPANY,
p_invoice_id: INVOICE,
p_rows: ROWS,
p_header: HEADER,
})
expect(history.from).toHaveBeenCalledWith('processing_history')
expect(history.insert).toHaveBeenCalledTimes(1)
const row = history.insert.mock.calls[0][0] as Record<string, unknown>
expect(row).toMatchObject({
company_id: COMPANY,
correlation_id: RUN,
aggregate_type: 'Invoice',
aggregate_id: INVOICE,
event_type: 'InvoiceRowsCompleted',
actor: { type: 'cron', id: 'complete-invoice-lines' },
payload_schema_version: 1,
})
expect(row.payload).toEqual({
source: 'complete-invoice-lines',
provider: 'fortnox',
consent_id: CONSENT,
rows: 2,
header_updated: true,
header_before: { subtotal: 1250, vat_amount: 0, vat_rate: 25, vat_treatment: 'standard_25' },
header_after: { subtotal: 1000, vat_amount: 250, vat_rate: 25, vat_treatment: 'standard_25' },
})
// The SEK twins are derived, not evidence: the trail leaves them out.
expect(row.payload).not.toHaveProperty('header_after.subtotal_sek')
expect(result).toEqual({ status: 'written', rows: 2, headerUpdated: true, eventId: row.event_id })
expect(typeof result.status === 'string' && 'eventId' in result && result.eventId).toMatch(/^[0-9a-f-]{36}$/)
})
it('records no split when the header was left alone (the wizard path, or evidence already there)', async () => {
const { client: supabase, rpc } = rpcClient(wrote(2, false))
const history = historyClient()
const result = await completeInvoiceRows(supabase, {
companyId: COMPANY,
invoiceId: INVOICE,
rows: ROWS,
trail: { ...trail, source: 'migration-wizard', actor: { type: 'user', id: '55555555-5555-4555-8555-555555555555' } },
historyClient: history.client,
})
// No header means an explicit null to the RPC, never a dropped argument.
expect(rpc.mock.calls[0][1]).toMatchObject({ p_header: null })
const row = history.insert.mock.calls[0][0] as Record<string, unknown>
expect(row.payload).toEqual({
source: 'migration-wizard',
provider: 'fortnox',
consent_id: CONSENT,
rows: 2,
header_updated: false,
header_before: null,
header_after: null,
})
expect(row.actor).toEqual({ type: 'user', id: '55555555-5555-4555-8555-555555555555' })
expect(result).toMatchObject({ status: 'written', rows: 2, headerUpdated: false })
})
it('records nothing when another writer filled the invoice first', async () => {
const { client: supabase } = rpcClient({ data: { ok: true, wrote: false, rows: 0, header_updated: false } })
const history = historyClient()
const result = await completeInvoiceRows(supabase, {
companyId: COMPANY, invoiceId: INVOICE, rows: ROWS, header: HEADER, headerBefore: BEFORE, trail,
historyClient: history.client,
})
expect(result).toEqual({ status: 'already_filled' })
expect(history.insert).not.toHaveBeenCalled()
})
it('records nothing when the RPC errors or refuses', async () => {
const history = historyClient()
const errored = rpcClient({ data: null, error: { message: 'check violation' } })
await expect(completeInvoiceRows(errored.client, {
companyId: COMPANY, invoiceId: INVOICE, rows: ROWS, trail, historyClient: history.client,
})).resolves.toEqual({ status: 'failed', reason: 'check violation' })
const refused = rpcClient({ data: { ok: false, code: 'MISSING_REQUIRED', details: { column: 'vat_rate' } } })
await expect(completeInvoiceRows(refused.client, {
companyId: COMPANY, invoiceId: INVOICE, rows: ROWS, trail, historyClient: history.client,
})).resolves.toEqual({ status: 'failed', reason: 'MISSING_REQUIRED' })
const empty = rpcClient({ data: null })
await expect(completeInvoiceRows(empty.client, {
companyId: COMPANY, invoiceId: INVOICE, rows: ROWS, trail, historyClient: history.client,
})).resolves.toEqual({ status: 'failed', reason: 'empty RPC response' })
expect(history.insert).not.toHaveBeenCalled()
})
it('reports a write whose trail append failed as written, with no event id', async () => {
// The rows are committed by then. Failing the invoice would make the
// next run try again and find it full; the gap is logged instead.
const { client: supabase } = rpcClient(wrote(2, true))
const history = historyClient({ message: 'insert or update on table "processing_history" violates foreign key constraint' })
const result = await completeInvoiceRows(supabase, {
companyId: COMPANY, invoiceId: INVOICE, rows: ROWS, header: HEADER, headerBefore: BEFORE, trail,
historyClient: history.client,
})
expect(history.insert).toHaveBeenCalledTimes(1)
expect(result).toEqual({ status: 'written', rows: 2, headerUpdated: true, eventId: null })
})
})
+189
View File
@@ -0,0 +1,189 @@
/**
* completeInvoiceRows: the one TypeScript call site for the
* complete_invoice_rows RPC (migration 20260906135730), and the one place the
* behandlingshistorik event for a completed migrated invoice is written.
*
* Two writers put rows under migrated sales invoices: the migration wizard
* (extensions/general/arcim-migration/lib/migration-orchestrator.ts, rows
* written milliseconds after the header) and the hourly row-completion pass
* (complete-invoice-lines.ts, the rows the wizard's hydration budget did not
* reach, plus the header VAT split when the stored one held no evidence).
* The RPC already gives them one write path; this wrapper gives them one
* trail. Every invoice whose rows the RPC wrote gets one InvoiceRowsCompleted
* event (BFL 5 kap 11 §, BFNAR 2013:2 p. 9.16: the behandlingshistorik has to
* say what was processed automatically, when, and by what) naming the writer,
* the provider the rows came from, the row count and, when the header split
* was rewritten, the split before and after. The pass writes no
* bokföringspost, so BFL 5 kap 5 § (rättelse) does not bind it; the migration
* that wrote the invoice header records no event of its own, so this event
* is what lets the two writers reconcile per invoice.
*
* Failure semantics: the event is written only after the RPC has answered
* wrote = true, so an invoice whose completion failed, or that another writer
* had already filled, gets no event. The append itself is best-effort, the
* convention every processing_history writer follows (the rows are
* committed; a missing change-log row is logged, not turned into a failed
* invoice that the next run would try again and find full). The caller sees
* a null eventId when that happened.
*
* PII boundary: the payload carries UUIDs, counts, amounts and enum strings
* only. Invoice numbers and provider document numbers are deliberately left
* out: a ten-digit number (2026090001) trips the personnummer guard in
* appendProcessingHistory, which would lose the event for exactly that
* invoice. The invoice id is the reference; its number is on the row.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ProcessingHistoryActor } from '@/types'
import { createLogger } from '@/lib/logger'
import {
appendProcessingHistoryWithClient,
type ProcessingHistoryEventType,
} from '@/lib/processing-history/append'
const log = createLogger('invoices/complete-invoice-rows')
/** Registered in processing_event_types by migration 20260906210100. */
export const INVOICE_ROWS_COMPLETED_EVENT = 'InvoiceRowsCompleted' satisfies ProcessingHistoryEventType
type RpcClient = Pick<SupabaseClient, 'rpc'>
type HistoryClient = Pick<SupabaseClient, 'from'>
/** The six invoice columns the RPC may rewrite: all present or none. */
export interface InvoiceHeaderVatSplit {
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
vat_rate: number | null
vat_treatment: string
}
/**
* What the trail records of a header split. The SEK twins are left out:
* they are derived from these and the exchange rate the row already carries.
*/
export interface InvoiceHeaderVatSnapshot {
subtotal: number | null
vat_amount: number | null
vat_rate: number | null
vat_treatment: string | null
}
export interface CompleteInvoiceRowsTrail {
/** Which writer: 'migration-wizard' or 'complete-invoice-lines'. */
source: string
/** The provider the rows came from ('fortnox', 'briox', ...). */
provider: string
/** The provider consent the rows were fetched under. */
consentId: string
/** One id per run, shared by every invoice the run completed. */
correlationId: string
actor: ProcessingHistoryActor
}
export interface CompleteInvoiceRowsInput {
companyId: string
invoiceId: string
/** The invoice_items columns per row; the RPC stamps invoice_id itself. */
rows: Record<string, unknown>[]
/** The header split to apply in the same transaction, or null to leave the header alone. */
header?: InvoiceHeaderVatSplit | null
/** The stored split before the write; recorded beside the new one when the header is rewritten. */
headerBefore?: InvoiceHeaderVatSnapshot | null
trail: CompleteInvoiceRowsTrail
/**
* The client the event row is written with. processing_history has no
* INSERT policy, so this must be a service-role client: the cron's own
* client already is, the wizard (on the user's session client) passes one.
*/
historyClient: HistoryClient
}
export type CompleteInvoiceRowsResult =
/** The rows (and the header, when one was given) landed; eventId is null when the trail append failed. */
| { status: 'written'; rows: number; headerUpdated: boolean; eventId: string | null }
/** Another writer filled the invoice first; nothing was written and nothing is recorded. */
| { status: 'already_filled' }
/** The RPC errored or refused (its code, or the Postgres message). */
| { status: 'failed'; reason: string }
/** What complete_invoice_rows returns (migration 20260906135730). */
interface CompleteRowsRpcOutcome {
ok: boolean
code?: string
wrote?: boolean
rows?: number
header_updated?: boolean
}
function snapshotOf(split: InvoiceHeaderVatSnapshot | InvoiceHeaderVatSplit | null | undefined): InvoiceHeaderVatSnapshot | null {
if (!split) return null
return {
subtotal: split.subtotal,
vat_amount: split.vat_amount,
vat_rate: split.vat_rate,
vat_treatment: split.vat_treatment,
}
}
export async function completeInvoiceRows(
supabase: RpcClient,
input: CompleteInvoiceRowsInput,
): Promise<CompleteInvoiceRowsResult> {
const header = input.header ?? null
const { data, error } = await supabase.rpc('complete_invoice_rows', {
p_company_id: input.companyId,
p_invoice_id: input.invoiceId,
p_rows: input.rows,
p_header: header,
})
const outcome = (data ?? null) as CompleteRowsRpcOutcome | null
if (error || !outcome?.ok) {
return { status: 'failed', reason: error?.message ?? outcome?.code ?? 'empty RPC response' }
}
if (!outcome.wrote) return { status: 'already_filled' }
const rows = outcome.rows ?? input.rows.length
const headerUpdated = outcome.header_updated === true
const eventId = await appendCompletedEvent(input, rows, headerUpdated)
return { status: 'written', rows, headerUpdated, eventId }
}
async function appendCompletedEvent(
input: CompleteInvoiceRowsInput,
rows: number,
headerUpdated: boolean,
): Promise<string | null> {
const { trail } = input
try {
return await appendProcessingHistoryWithClient(input.historyClient, {
companyId: input.companyId,
correlationId: trail.correlationId,
aggregateType: 'Invoice',
aggregateId: input.invoiceId,
eventType: INVOICE_ROWS_COMPLETED_EVENT,
payload: {
source: trail.source,
provider: trail.provider,
consent_id: trail.consentId,
rows,
header_updated: headerUpdated,
header_before: headerUpdated ? snapshotOf(input.headerBefore) : null,
header_after: headerUpdated ? snapshotOf(input.header) : null,
},
actor: trail.actor,
occurredAt: new Date(),
})
} catch (err) {
// The rows are committed; the trail row is what is missing. Logged so
// the gap is visible, never a reason to report the invoice as failed.
log.error('InvoiceRowsCompleted append failed; rows written without their behandlingshistorik row', {
companyId: input.companyId,
invoiceId: input.invoiceId,
source: trail.source,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
+1
View File
@@ -60,6 +60,7 @@ export const PROCESSING_EVENT_TYPES = [
'InvoiceDuplicatePaymentDismissed',
'InvoiceJournalEntrySkipped',
'InvoicePaymentRowBackfilled',
'InvoiceRowsCompleted',
'OAuthClientRevoked',
'PendingOperationApproved',
'PendingOperationRejected',