Files
accounted/supabase/migrations/20260727160000_supplier_invoice_overdue_symmetric.sql
Jakob Wennberg df29817826 fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice (#1227)
* fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice

The daily cron flips unbooked payables past their due date to 'overdue' but
nothing ever flipped them back, so aging alone pushed an invoice out of every
workflow that gated on 'registered': it could not be edited (not even to extend
the due date that made it overdue) and it could not be attested. Deletion was
already unblocked in #1204; this closes the rest of #1206.

- update_overdue_supplier_invoices() gains the inverse branch: a payable whose
  due date is no longer in the past returns to its resting status. Because the
  flip collapses 'registered' and 'approved', the un-flip needs a separate
  attest marker: new supplier_invoices.approved_at, backfilled from updated_at
  for rows currently sitting in 'approved'.
- PUT /api/supplier-invoices/[id] accepts every unsettled status and recomputes
  the label from the due date it writes, in both directions, instead of leaving
  it up to a day stale. The update body carries metadata only (numbers, dates,
  reference, notes), never amounts or accounts, so a posted registration
  verifikat cannot be desynced by money.
- Approve (web route, v1 API, MCP staging tool, staged commit executor) keys off
  approved_at instead of status === 'registered', so an aged invoice can still
  be attested. A still-late invoice keeps the 'overdue' label after attest:
  approving is not a reason to hide that the money is late.
- One shared predicate in lib/supplier-invoices/lifecycle.ts for all five call
  sites, mirroring the SQL; new SI_EDIT_INVALID_STATUS replaces the raw Swedish
  string the edit gate used to return.

Tests: 12 pg-real cases on the cron (5 new, covering both directions and the
credit-note/fully-paid boundaries), plus route tests asserting the exact written
payload for PUT and approve, and unit tests pinning the shared predicate against
the SQL. npm test (11385), lint, check:guards clean.

Closes #1206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(migration): mark backfilled approved_at values as derived, not audit facts

Compliance review on #1227 flagged that approved_at = updated_at could later be
mistaken for an observed attestation moment (BFNAR 2013:2 kap 8
behandlingshistorik). The column comment and the migration now state plainly
that pre-migration values are derived and that audit_log, written by the
audit_supplier_invoices trigger, remains the record of what happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): guard the derived status writes with compare-and-swap

Review findings on #1227. The status these paths write is derived from facts
read a moment earlier, so an unconditional write could overwrite a concurrent
cron flip, edit or approval with a label computed from what those changed.

- PUT pins status, due_date and approved_at when (and only when) it derives a
  new status; zero matched rows is now a retryable 409 SI_EDIT_CONFLICT instead
  of a silently stale label. Metadata-only updates keep writing unconditionally:
  they never touch status, so they cannot clobber it.
- The web approve route and the staged-commit executor gain the same
  pre-approval guard the v1 route already had (status in registered/overdue,
  approved_at IS NULL) plus a !data race check, so two concurrent approvals can
  no longer both stamp approved_at and both emit supplier_invoice.approved.
- The v1 guard additionally pins due_date, since nextStatus is derived from it.
- The list page no longer invents status/approved_at when the approve response
  is incomplete: it re-reads instead. An operator about to pay must not be shown
  a fabricated lifecycle state.
- route.overdue.test.ts clears the module-level event bus like its sibling.

Tests: new conflict cases for both paths (409 on PUT, refusal without an event
emission on approve). npm test 11387 passed, lint 0 errors, check:guards clean,
12 pg-real cases green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:49:22 +02:00

77 lines
3.8 KiB
PL/PgSQL

-- Migration: supplier_invoice_overdue_symmetric
--
-- Issue #1206: 'overdue' was a one-way label. update_overdue_supplier_invoices()
-- (the daily pg_cron job from 20260303145744, guarded in 20260607120000) flips
-- 'registered'/'approved' payables past their due date to 'overdue', but
-- nothing ever flipped them back. Extending an unbooked invoice's due date
-- (renegotiated terms, a mistyped date) therefore left it "Förfallen" forever,
-- and until #1204 it could not even be deleted.
--
-- Two parts:
-- 1. approved_at: the flip collapses 'registered' and 'approved' into the
-- same 'overdue' row, so the way back needs a separate record of whether
-- the invoice was ever attested. Without it every un-flip would strip an
-- approved invoice of its approval.
-- 2. The cron becomes symmetric: a payable whose due date is no longer in
-- the past returns to its resting status.
-- 1. Attest timestamp --------------------------------------------------------
ALTER TABLE public.supplier_invoices
ADD COLUMN IF NOT EXISTS approved_at timestamptz;
COMMENT ON COLUMN public.supplier_invoices.approved_at IS
'When the invoice was attested (godkänd). Written by the approve paths; the overdue un-flip reads it to choose between ''registered'' and ''approved''. Workflow marker, not räkenskapsinformation: values on rows updated before 2026-07-27 were backfilled from updated_at (no approval log existed) and are therefore derived, not observed attestation moments. Do not use it as an audit fact for those rows; audit_log holds the actual transitions.';
-- Backfill for rows that currently sit in 'approved': updated_at is the closest
-- available proxy (there is no approval log), and the value only ever decides
-- an un-flip target, never a money field. Rows already ON 'overdue' are
-- deliberately left NULL: whether they were approved before the flip is
-- unknowable, and 'registered' is the safe, re-approvable resting state.
--
-- These backfilled timestamps are derived, not observed: the column comment
-- above says so, and audit_log (via the audit_supplier_invoices trigger) stays
-- the record of what actually happened and when. Nothing reads approved_at as
-- an audit fact; it is a workflow marker for the flip/un-flip decision.
UPDATE public.supplier_invoices
SET approved_at = updated_at
WHERE approved_at IS NULL
AND status = 'approved';
-- 2. Symmetric cron ----------------------------------------------------------
-- CREATE OR REPLACE rewrites the whole definition, so re-declare the
-- search_path that 20260304191528_set_search_path_on_functions.sql pinned.
CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Flip. Unchanged from 20260607120000: 0.005 mirrors the "fully paid"
-- threshold used by the payment/match paths, and credit notes
-- (kreditfakturor) are not payables.
UPDATE supplier_invoices
SET status = 'overdue',
updated_at = NOW()
WHERE due_date < CURRENT_DATE
AND status IN ('registered', 'approved')
AND remaining_amount > 0.005
AND COALESCE(is_credit_note, false) = false;
-- Un-flip: the exact inverse of the predicate above. Once the due date is no
-- longer in the past the invoice is not overdue, so it returns to the status
-- the flip collapsed. Fully-paid and credit-note rows stuck on 'overdue' are
-- left alone here: they were repaired once by the backfill in 20260607120000
-- and the flip can no longer produce them.
UPDATE supplier_invoices
SET status = CASE WHEN approved_at IS NOT NULL THEN 'approved' ELSE 'registered' END,
updated_at = NOW()
WHERE status = 'overdue'
AND due_date >= CURRENT_DATE
AND remaining_amount > 0.005
AND COALESCE(is_credit_note, false) = false;
END;
$$;
NOTIFY pgrst, 'reload schema';