From df29817826300812bbc0b8ed550ee2cbcd7d6e8d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:49:22 +0200 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 1 + .../supplier-invoices/[id]/page.tsx | 6 +- app/(dashboard)/supplier-invoices/page.tsx | 22 +- .../[id]/__tests__/route.put.test.ts | 212 ++++++++++++++++++ .../approve/__tests__/route.overdue.test.ts | 164 ++++++++++++++ .../supplier-invoices/[id]/approve/route.ts | 39 +++- app/api/supplier-invoices/[id]/route.ts | 72 +++++- .../supplier-invoices/[id]/approve/route.ts | 64 ++++-- .../supplier-invoices/__tests__/route.test.ts | 33 ++- extensions/general/mcp-server/server.ts | 11 +- lib/errors/structured-errors.ts | 18 +- lib/pending-operations/commit.ts | 39 +++- .../__tests__/lifecycle.test.ts | 102 +++++++++ lib/supplier-invoices/lifecycle.ts | 96 ++++++++ ...000_supplier_invoice_overdue_symmetric.sql | 76 +++++++ tests/helpers.ts | 1 + .../supplier-invoice-overdue-cron.pg.test.ts | 101 ++++++++- types/index.ts | 6 + 18 files changed, 1014 insertions(+), 49 deletions(-) create mode 100644 app/api/supplier-invoices/[id]/__tests__/route.put.test.ts create mode 100644 app/api/supplier-invoices/[id]/approve/__tests__/route.overdue.test.ts create mode 100644 lib/supplier-invoices/__tests__/lifecycle.test.ts create mode 100644 lib/supplier-invoices/lifecycle.ts create mode 100644 supabase/migrations/20260727160000_supplier_invoice_overdue_symmetric.sql diff --git a/DECISIONS.md b/DECISIONS.md index a804541c..6694f821 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -588,3 +588,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-27] hashApiKey stays SHA-256 against CodeQL js/insufficient-password-hash: the input is 32 CSPRNG bytes, not a user-chosen password, so no KDF work factor is meaningful against 256 bits; the hash is also the primary-key lookup on every MCP request, and changing it would invalidate every live gnubok_sk_ key since the hash IS the stored credential. [2026-07-27] mcp-oauth consent form action is HTML-escaped even though the CodeQL js/reflected-xss finding is not exploitable (WHATWG URL parsing already percent-encodes " < > in the query component): & is not in that encode set so the attribute was emitting invalid raw ampersands, and resting the page on an unstated parser-normalisation invariant is one refactor away from being wrong. [2026-07-27] Compliance-review artifact unpacks to runner.temp instead of the workspace root: extracting fork-influenced content over the trusted checkout, with AWS secrets in scope, was safe only because stage 1 happens to write fixed filenames; moving it makes overwrite unreachable by construction. +[2026-07-27] Supplier-invoice 'overdue' stays a stored status, made symmetric instead of derived (#1206): added approved_at as the durable attest marker and an un-flip branch in update_overdue_supplier_invoices(), rather than computing overdue at read time. Computing it would have touched every list/filter/report query that reads status plus the v1 API contract; the symmetric-cron fix is the same user-visible outcome at a fraction of the blast radius. diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 5de975f7..b685de19 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -26,6 +26,7 @@ import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton' import { useCompanySettings } from '@/components/settings/useSettings' import { formatAmount, formatCurrency } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' +import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle' import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types' interface EditableLine { @@ -490,7 +491,10 @@ export default function SupplierInvoiceDetailPage() { contextRef={`supplier_invoice:${invoice.id}`} size="default" /> - {invoice.status === 'registered' && !invoice.is_credit_note && ( + {/* Attest keys off approved_at, not the status: the overdue cron + flips unbooked invoices to 'overdue' just by aging, and gating on + 'registered' alone left them with no way through attest (#1206). */} + {canApproveSupplierInvoice(invoice) && !invoice.is_credit_note && (