fix(invoices): v1 mark-paid booking-state routing + journal_entry_id backfill (#713)

invoices.journal_entry_id means "the registration verifikat that booked
this invoice at issuance" — payment flows route on it (set → clear 1510,
NULL → kontantmetoden cash entry). Two bugs in v1 mark-paid broke that:

- The pre-flight select omitted journal_entry_id, so invoiceAlreadyBooked
  always read false — a kontantmetoden company paying an already-registered
  invoice would re-recognise revenue + VAT (double-booking) and orphan the
  1510 receivable. Fixed by fetching the column for routing only; the
  response contract and invoice.paid event payload are unchanged.
- The update wrote the just-created PAYMENT/cash entry id into the column
  (wrong semantic) — once routing reads the column, a cash partial payment
  #1 would make payment #2 clear a 1510 that was never debited. Removed;
  the payment entry id still returns in the response body.

New backfill migration links the earliest posted invoice_created entry to
historical invoices (353 registered-but-unlinked rows in hosted prod),
repairs any payment-type links, and links credit_note reversal entries to
credit-note rows. Idempotent; rows with no registration entry stay NULL
(correct for kontantmetoden/unsent invoices).

Tests: 3 new unit tests lock the select projection, the already-booked→
clearing routing, and the no-write-back semantics (the supabase mock now
records call args). New pg-real suite (11 tests) runs the actual migration
SQL: earliest-wins, reversed/draft exclusion, no-overwrite, cash stays
NULL, payment-link repair, credit notes, cross-company isolation,
idempotency. insertDraftJournalEntry fixture gains optional sourceType/
sourceId/createdAt (defaults unchanged).

Hosted prod requires manual migration apply after merge (Supabase MCP).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-11 11:51:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0521c385d2
commit b7f60b23f5
5 changed files with 535 additions and 9 deletions
@@ -0,0 +1,72 @@
-- Migration: backfill invoices.journal_entry_id (registration entry linkage)
--
-- The column (added in 20260613100000_self_billing_received_invoices.sql)
-- means "the verifikat that BOOKED this invoice at issuance" — the
-- invoice_created registration entry (Dr 1510 / Cr 30xx+26xx), or the
-- credit_note reversal entry on a credit-note row. Payment flows route on it:
-- set → clearing entry (Dr 1930 / Cr 1510); NULL → kontantmetoden cash entry
-- (Dr 1930 / Cr 30xx+26xx). Write-backs only shipped ~2026-06-05, so nearly
-- all historical rows are NULL even where a posted registration entry exists —
-- a company on (or switching to) kontantmetoden would double-book revenue +
-- VAT when marking those invoices paid.
--
-- Passes 1–2 touch ONLY rows that are still NULL, so the migration is
-- idempotent and safe to re-run / resume after an interruption. Rows with no
-- posted registration entry stay NULL — that is the CORRECT value for
-- kontantmetoden / unsent / proforma invoices (revenue is recognised at
-- payment instead).
-- Pass 0 — defensive repair. An earlier version of the v1 mark-paid route
-- wrote the PAYMENT/cash entry id into this column (wrong semantic: it would
-- make a kontantmetoden invoice look registered, so a later partial payment
-- clears a 1510 that was never debited). Hosted prod has no such rows, but
-- self-hosted databases that ran that code may. Null them out so Pass 1 can
-- re-link the correct registration entry where one exists.
UPDATE public.invoices i
SET journal_entry_id = NULL
FROM public.journal_entries je
WHERE je.id = i.journal_entry_id
AND je.source_type IN ('invoice_paid', 'invoice_cash_payment');
-- Pass 1 — registration entries. Earliest posted invoice_created entry per
-- invoice; status='posted' excludes reversed/cancelled/draft (a stornoed
-- registration must not mark the invoice as booked). DISTINCT ON with the
-- created_at,id ordering gives a deterministic pick if duplicates exist.
-- The company_id equality guard is defense-in-depth against cross-tenant
-- uuid collisions. idx_journal_entries_source (source_type, source_id) makes
-- the subquery cheap.
UPDATE public.invoices i
SET journal_entry_id = je.id
FROM (
SELECT DISTINCT ON (company_id, source_id) id, company_id, source_id
FROM public.journal_entries
WHERE source_type = 'invoice_created'
AND status = 'posted'
AND source_id IS NOT NULL
ORDER BY company_id, source_id, created_at ASC, id ASC
) je
WHERE i.journal_entry_id IS NULL
AND je.source_id = i.id
AND je.company_id = i.company_id;
-- Pass 2 — credit-note reversal entries onto credit-note rows
-- (source_type='credit_note', source_id = the credit note's own invoice row
-- id; matches the live write sites: v1 credit route, app/api/invoices POST).
-- Payment routing never reads these (mark-paid rejects credit notes) — this
-- pass exists for the dashboard verifikat link and linkage completeness.
UPDATE public.invoices i
SET journal_entry_id = je.id
FROM (
SELECT DISTINCT ON (company_id, source_id) id, company_id, source_id
FROM public.journal_entries
WHERE source_type = 'credit_note'
AND status = 'posted'
AND source_id IS NOT NULL
ORDER BY company_id, source_id, created_at ASC, id ASC
) je
WHERE i.journal_entry_id IS NULL
AND i.credited_invoice_id IS NOT NULL
AND je.source_id = i.id
AND je.company_id = i.company_id;
NOTIFY pgrst, 'reload schema';