Files
accounted/lib/processing-history/append.ts
T
Mattsson e2d38b0ab3 fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)
* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)

settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.

- Insert the row between voucher creation and the CAS status update, same
  shape as the bank-match path (amount in invoice currency, transaction_id
  null). An insert failure cancels the voucher and fails closed; both CAS
  failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
  a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
  only where exactly one posted payment voucher exists; zero or several are
  reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
  reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
  the je/invoice index still refuses a double link, and the authenticated
  writer can delete its own row (the CAS-failure path depends on it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* fix(invoices): write the payment row from every mark-paid path and harden the backfill

Skeptic and review round on #2236 (issue #2019):

- One helper (lib/invoices/invoice-payment-row.ts) now writes the
  invoice_payments row for all four transaction-less settlement paths:
  dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
  mark_invoice_paid commit and the v1 mark-paid route, which booked their
  own voucher and never wrote the row. Amount = applied amount (new
  paid_amount minus prior), not cash received, so a 3740 öre absorption
  never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
  transaction_id NULL as "reconciled to a bank line": the bank line for a
  manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
  wall-clock before #1332); refuse rows that disagree with the voucher's
  1510 credit / settlement debit; report partially covered invoices
  (rows_short) instead of patching; record each executed run in
  behandlingshistorik (InvoicePaymentRowBackfilled, migration
  20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event

Swedish review round 2 on #2236:

- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
  it logs at error level with company and row id (a stranded row would
  read as a settlement in the kontantmetod cut-off) and returns whether
  the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
  behandlingshistorik event cannot be written, so rows and change log
  (BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses

Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* fix(invoices): never backfill a payment row into a closed or locked period

Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo

* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)

cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:42:43 +02:00

246 lines
8.9 KiB
TypeScript

/**
* Processing history (behandlingshistorik): append helper.
*
* Uses a service-role client internally (no INSERT RLS policy on
* processing_history: matching the event_log pattern). Company scoping
* is enforced by companyId in the event payload, not by RLS.
* Throws on failure.
*
* PII BOUNDARY: payload MUST contain pseudonymous IDs only (user UUIDs,
* company UUIDs, counterparty IDs). Never names, emails, personnummer,
* addresses, or phone numbers. These live in their source tables (profiles,
* customers, suppliers) and are referenced by ID. GDPR erasure pseudonymizes
* the source tables; processing_history events become undecipherable by
* reference, which is the required behavior per v0.2 §10.
*/
import type {
ProcessingHistoryAggregateType,
ProcessingHistoryActor,
} from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClient } from '@/lib/supabase/server'
import { z } from 'zod'
/**
* Any service-role client with the query surface the append needs. Structural
* so both the Next-bound createServiceClient() and a script's own
* createClient(url, serviceRoleKey) satisfy it.
*/
type SupabaseClientLike = Pick<SupabaseClient, 'from'>
// ── Event type catalog ──────────────────────────────────────────
// Every event type the code emits, and the contract with the
// processing_event_types reference table: processing_history.event_type has an
// FK to it, and every append call site is best-effort try/catch, so a type
// that is missing from the table fails the insert silently and the act leaves
// no durable record at all. Ten types drifted out of the table exactly that
// way before this list existed.
//
// Adding an entry here therefore REQUIRES a migration registering the same
// string in public.processing_event_types, in the same change. The union
// makes an unregistered literal a compile error;
// tests/pg/processing-event-types.pg.test.ts makes an unregistered string a
// test failure. Keep it sorted.
export const PROCESSING_EVENT_TYPES = [
'AttachmentsTruncated',
'BankTransactionDuplicateDismissed',
'ChannelQuestionAnswered',
'ChannelQuestionAsked',
'ChannelQuestionExpired',
'DocumentDuplicateSkipped',
'DocumentExtractionAttempted',
'DocumentExtractionOverridden',
'DocumentExtractionRetried',
'DocumentIngested',
'InboxUnderlagReconciled',
'InvoiceDuplicatePaymentDismissed',
'InvoiceJournalEntrySkipped',
'InvoicePaymentRowBackfilled',
'OAuthClientRevoked',
'PendingOperationApproved',
'PendingOperationRejected',
'RateLimitedDropped',
'TransactionDocumentReplaced',
] as const
export type ProcessingHistoryEventType = (typeof PROCESSING_EVENT_TYPES)[number]
// ── PII validator ───────────────────────────────────────────────
// Rejects payloads containing Swedish personal identity numbers.
// Personnummer: YYMMDD-NNNN or YYMMDDNNNN (6+4 digits)
// Samordningsnummer: Same format but day +60
// Organisationsnummer: NNNNNN-NNNN (10 digits, but we catch the pattern)
// Word boundaries prevent false positives on Bankgiro (123456-7890) and
// invoice references like 202312-1234 that share the digit shape but aren't PII.
const PII_PATTERNS = [
/\b\d{6}-?\d{4}\b/, // personnummer, samordningsnummer
/\b\d{8}-?\d{4}\b/, // 12-digit variant (YYYYMMDD-NNNN) or orgnr
]
// UUIDs (RFC 4122, 8-4-4-4-12 hex layout) frequently contain all-digit segments
// that incorrectly match the 8+4 personnummer pattern: e.g. `57484518-3409-...`.
// Strip UUID-shaped substrings before PII matching so legitimate identifiers
// aren't rejected. Personnummer always sit outside the UUID shape, so this keeps
// the original safety intent intact.
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
function stringContainsPii(value: string): boolean {
const stripped = value.replace(UUID_PATTERN, '')
return PII_PATTERNS.some(pattern => pattern.test(stripped))
}
function containsPii(value: unknown): boolean {
if (typeof value === 'string') {
return stringContainsPii(value)
}
if (Array.isArray(value)) {
return value.some(containsPii)
}
if (value !== null && typeof value === 'object') {
return Object.values(value).some(containsPii)
}
return false
}
const piiSafePayload = z.record(z.string(), z.unknown()).refine(
(payload) => !containsPii(payload),
{ message: 'Payload contains PII (personnummer/samordningsnummer/orgnr pattern). Use pseudonymous IDs only.' }
)
function assertActorPiiSafe(actor: ProcessingHistoryActor): void {
if (actor.label && stringContainsPii(actor.label)) {
throw new Error(
'actor.label contains PII (personnummer/samordningsnummer/orgnr pattern). Use a pseudonymous descriptor only.'
)
}
}
// ── Input type ──────────────────────────────────────────────────
export interface AppendEventInput {
companyId: string
correlationId: string
causationId?: string
aggregateType: ProcessingHistoryAggregateType
aggregateId: string
eventType: ProcessingHistoryEventType
payload: Record<string, unknown>
payloadSchemaVersion?: number
actor: ProcessingHistoryActor
rubricVersion?: string
occurredAt: Date // mandatory: no default. Caller must set explicitly.
}
// ── Append functions ────────────────────────────────────────────
/**
* Append a single event to processing_history.
*
* Uses a service-role client internally (bypasses RLS) since processing_history
* has no INSERT policy: matching the event_log pattern. Company scoping is
* enforced by the companyId in the event payload, not by RLS.
*
* Returns the generated event_id (pre-generated client-side for causation chaining).
*/
export async function appendProcessingHistory(
input: AppendEventInput
): Promise<string> {
return appendProcessingHistoryWithClient(createServiceClient(), input)
}
/**
* Same append, on a caller-supplied service-role client. For standalone
* scripts (e.g. scripts/backfill-inbox-booked-underlag.ts) that cannot build
* the Next-bound service client but must still write behandlingshistorik
* through the one shared row shape and PII validation (BFNAR 2013:2 p. 9.16:
* the change log has to reconcile across writers, so scripts never hand-roll
* the insert).
*/
export async function appendProcessingHistoryWithClient(
supabase: SupabaseClientLike,
input: AppendEventInput
): Promise<string> {
// Validate payload + actor.label contain no PII
piiSafePayload.parse(input.payload)
assertActorPiiSafe(input.actor)
const eventId = crypto.randomUUID()
const { error } = await supabase
.from('processing_history')
.insert({
event_id: eventId,
company_id: input.companyId,
correlation_id: input.correlationId,
causation_id: input.causationId ?? null,
aggregate_type: input.aggregateType,
aggregate_id: input.aggregateId,
event_type: input.eventType,
payload: input.payload,
payload_schema_version: input.payloadSchemaVersion ?? 1,
actor: input.actor,
rubric_version: input.rubricVersion ?? null,
occurred_at: input.occurredAt.toISOString(),
})
if (error) {
throw new Error(
`Failed to append processing_history event ${input.eventType}: ${error.message}`
)
}
return eventId
}
/**
* Append multiple events atomically (single INSERT).
* Used for batch operations (e.g., migration commits, multi-event command handlers).
*
* Returns array of generated event_ids in input order.
*/
export async function appendProcessingHistoryBatch(
inputs: AppendEventInput[]
): Promise<string[]> {
if (inputs.length === 0) return []
const eventIds = inputs.map(() => crypto.randomUUID())
// Validate all payloads + actor labels before any DB write
for (const input of inputs) {
piiSafePayload.parse(input.payload)
assertActorPiiSafe(input.actor)
}
const rows = inputs.map((input, i) => ({
event_id: eventIds[i],
company_id: input.companyId,
correlation_id: input.correlationId,
causation_id: input.causationId ?? null,
aggregate_type: input.aggregateType,
aggregate_id: input.aggregateId,
event_type: input.eventType,
payload: input.payload,
payload_schema_version: input.payloadSchemaVersion ?? 1,
actor: input.actor,
rubric_version: input.rubricVersion ?? null,
occurred_at: input.occurredAt.toISOString(),
}))
const supabase = createServiceClient()
const { error } = await supabase
.from('processing_history')
.insert(rows)
if (error) {
throw new Error(
`Failed to append processing_history batch (${inputs.length} events): ${error.message}`
)
}
return eventIds
}