From 1270b6daeb467b996c08fc26e806e42ddf657a48 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:49:10 +0200 Subject: [PATCH] =?UTF-8?q?fix(bookkeeping):=20let=20a=20r=C3=A4ttelseveri?= =?UTF-8?q?fikation=20be=20stornoed;=20unblock=20aged=20supplier-invoice?= =?UTF-8?q?=20deletion=20(#1204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion A user who corrected a booking (storno + rättelse) and then discovered the affärshändelse was already booked by another verifikat had no sanctioned way out: reverseEntry refused source_type 'correction' alongside 'storno', and correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same guard also broke uncategorize-after-rättelse, since bank transactions are relinked to the correction entry. - reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain ambiguity problem); a correction entry is a regular live verifikat and can be stornoed, with correction_of_id keeping the chain traceable. - CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint. - Supplier-invoice DELETE now allows unbooked, unpaid invoices in registered/approved/overdue: the daily overdue cron flipped unbooked invoices past due_date into a state where deletion was blocked forever. Orphan-safety checks (registration JE, payments, accrual schedule) are what actually protect the books. UI shows the delete button accordingly. - LinkVoucherPicker showed customer-side copy (kundfordran/1510) in supplier-invoice mode; supplier mode now explains the 2440-debit requirement, including why a direct-cost verifikat cannot be linked. Support case 2026-07-26 (marcus@). Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded - The payment and accrual-schedule lookups in DELETE now fail closed: a lookup error returns 500 instead of reading as "nothing linked" and letting the delete proceed unverified. - The delete button also requires the loaded payment list to be empty, matching the server predicate. Co-Authored-By: Claude Fable 5 * docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + .../supplier-invoices/[id]/page.tsx | 29 ++-- .../[id]/__tests__/route.test.ts | 88 +++++++++++- app/api/supplier-invoices/[id]/route.ts | 44 +++++- components/invoices/LinkVoucherPicker.tsx | 16 ++- lib/bookkeeping/__tests__/engine.test.ts | 127 ++++++++++++++---- lib/bookkeeping/engine.ts | 15 ++- lib/bookkeeping/errors.ts | 12 +- .../__tests__/get-error-message.test.ts | 12 +- lib/errors/structured-errors.ts | 10 +- messages/en.json | 2 + messages/sv.json | 2 + 12 files changed, 291 insertions(+), 68 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 534835e6..eea2d968 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -385,3 +385,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched. [2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites. [2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted. +[2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity). +[2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books. diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 3b699c4f..5de975f7 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -491,15 +491,23 @@ export default function SupplierInvoiceDetailPage() { size="default" /> {invoice.status === 'registered' && !invoice.is_credit_note && ( - <> - + + )} + {/* Delete is allowed while nothing would be orphaned: no booking, no + payments (server re-checks). 'approved'/'overdue' are included + because the overdue cron flips unbooked invoices there and a + registered-only gate made them undeletable just by aging. */} + {['registered', 'approved', 'overdue'].includes(invoice.status) && + !invoice.is_credit_note && + !invoice.registration_journal_entry_id && + payments.length === 0 && ( - - )} + )} {['approved', 'overdue', 'partially_paid'].includes(invoice.status) && ( <>