From 00167322327c946e055b35810656d84a2a703359 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:24:28 +0200 Subject: [PATCH] feat(invoices): let the invoice list read by row tone without the status column (#2353) * feat(invoices): let the invoice list read by row tone without the status column A user asked for green/yellow/red row tints in the fakturalista (#2215) so the list can be read without the last column. The need is real; the tint is not the answer the design system already gives: status colours are data, not chrome (convention 12), and DECISIONS 2026-08-03 already ruled out row tints for this list. Settled rows (paid, cancelled, credited) now recede to muted text as a whole row. Open rows keep the foreground and the overdue chip stays the one marker on its row, so three row classes read at a glance with zero new colour: grey is done, dark is waiting for money, dark with an ochre chip is late. Works in dark mode and the brand themes for free and never stacks with the hover or selection tints. The tone lives in a pure lib helper (invoiceRowTone, tested) and one Record in the page maps tone to class; a row tint or a left-edge bar is a three-string swap there if the founder prefers colour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * docs(decisions): record the first-principles choices of the 2026-09-06 evening issue batch Ten lines for #2215 #2351 #2218 #2220 #2312 #2184 #2293 #2239 #2331 #2332 (PRs #2353 to #2362), carried by this PR so the other nine branches do not all touch DECISIONS.md. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 10 +++++ app/(dashboard)/invoices/page.tsx | 17 ++++++++ .../__tests__/invoice-list-row-tone.test.ts | 43 +++++++++++++++++++ lib/invoices/invoice-list-row-tone.ts | 34 +++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 lib/invoices/__tests__/invoice-list-row-tone.test.ts create mode 100644 lib/invoices/invoice-list-row-tone.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5a5b26fe..85bc1366 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1626,3 +1626,13 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-06] #2062 WhatsApp residuals: a send that THREW (timeout) rolls the company question back instead of resending as numbered text, because Meta may already have delivered it; only an HTTP rejection falls back. The two drains got the 30-day media cutoff plus a one-line M20 notice, and a code whose lookup failed gets a neutral M21 retry instead of M2: both are new bot copy written without a founder sign-off, so the wording is his to change, but silent expiry was the exact destroy-work pattern the #1992 review named. [2026-09-06] Issue #2057 (pre-#1990 stranded rows, is_business = true with no verifikat anchor, invisible in Att bokfora): repaired through a service_role-only RPC repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked, p_actor) driven by scripts/repair-stranded-categorized-transactions.ts, not a script-side UPDATE and not a worklist predicate change. PostgREST cannot re-assert the three NOT EXISTS anchor legs inside an UPDATE, so a select-then-update script could clobber a row booked in between; the RPC re-checks is_transaction_booked() in the same statement and writes one BankTransactionStrandedRepaired behandlingshistorik event per row in the same transaction. Widening the att-bokfora predicate to "not booked" was rejected: is_business IS NULL is load-bearing (lockPeriod guard, badges, idx_transactions_company_unbooked, RPCs, MCP), the 2026-08-27 decision chose fail-closed over a fourth state, and a predicate change would resurface every company's rows at once with no per-company decision and no audit record. The write is dry-run by default, needs one company id plus an actor, skips sandbox companies unless asked, and skips rows in locked or closed periods unless asked (Swedish review on PR #2350: a row returned to Att bokfora there cannot be booked in place, BFL 5 kap 5 §, so reopening it for triage is an explicit choice). The prod run waits for the founder's go per company. [2026-09-06] reverseEntry releases the bank rows of a reversed verifikat through one RPC, release_reversed_entry_transactions (migration 20260906172540): a single data-modifying CTE nulls the pointer column (journal_entry_id, is_business, category, reconciliation_method) of every transaction that pointed at the reversed entry AND drops those rows' transaction_voucher_links to other verifikat (a residual booking's role 'other' anchor to the small residual verifikat, #2061, Option B of the issue). Chosen over teaching each role-blind gate (bulk_book_transactions' junction EXISTS, fetchJunctionLinkedTxIds behind the unmatched list, is_transaction_booked(), the reconciliation bridge) the bank_line-only rule: that forks the meaning of "booked" per surface and keeps counting a row as matched while the ledger explains a few kronor of it. First cut was two PostgREST statements (read ids, reset, delete); CodeRabbit's point that a failed read or a link created between the reset and the delete recreates the half-anchored row was right, so the reset and the drop moved into one statement under the UPDATE's row locks. SECURITY INVOKER so RLS and the writer-role trigger apply exactly as to the direct statements. Links to the reversed entry itself stay with the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). The residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. The 2026-08-29 bank_line-only re-booking guards stay as defense for rows left behind earlier; measured 2026-09-06 on prod, zero non-bank_line junction rows exist, so nothing needs repair. transaction_voucher_links is a mutable reconciliation index (ON DELETE CASCADE from both sides, plain member delete policy, koppla-bort deletes it freely), not rakenskapsinformation, so a hard delete does not touch the BFL audit chain. +[2026-09-06] Invoice-list status at a glance (#2215) built as settled rows receding to muted text, not the requested sage/ochre/terracotta row tints: tints reverse the 2026-08-03 "not row tints" entry and convention 12 (status colours are data, not chrome), paint the majority state across the Alla view, and stack with the hover/selection tints; muted-foreground gives the open-vs-done split for free in every theme, and the overdue chip stays the one marker on its row. Tone maps through a single Record in the page so a tint or left-edge bar is a three-string swap if the founder prefers colour. +[2026-09-06] Periodisk sammanställning attributes a storno or correction to the invoice explaining the entry it cancels or replaces by following reverses_id / correction_of_id in a new composed resolver (getInvoicesExplainingJournalEntries), not by trusting the storno's copied source_id or adding storno/correction to the engine set as #2351 proposed: correctEntry() copies no source_id onto its storno or correction, and a storno's copied source_id is polymorphic (a bank row on a bank booking), so only the link columns cover both writers; the walk is kept out of getInvoiceReferencesForJournalEntries because that is the exact TS mirror of the RPC customer arm and storno/correction are not doc-requiring source types, so the underlag surfaces have no such hole and would otherwise change verdicts outside the issue. +[2026-09-06] Org-number autofill on the customer/supplier forms is automatic (once per distinct valid legal-person number, silent on 503/failure) over a read-only GET /api/parties/registry, not a button and not the per-party enrich route: SCB's API is free and the hook bounds calls; the enrich route needs a party that does not exist yet; a personnummer is refused on both sides. +[2026-09-06] Inlagd i banken (#2220) is a nullable supplier_invoices.bank_entered_at cleared by a trigger when a payment lands, not a file-less supplier_payment_batch: the batch is an immutable pain.001 snapshot (NOT NULL debtor/payee/reference, RPC refuses suppliers without payee and companies without IBAN), so a manual mark would have to fake every field the file needs and put one file-less batch per tick on Betalfiler. +[2026-09-06] InvoiceRowsCompleted is emitted by one core wrapper (lib/invoices/complete-invoice-rows.ts) on a new Invoice aggregate, not inside the RPC and not as a per-run blob: no processing_history writer lives in SQL (one row shape and PII guard in append.ts), per-invoice before/after is what #2291 asked for, and invoice/provider numbers stay out of the payload because the personnummer guard drops ten-digit ones. +[2026-09-06] Standard verifikationsserier (#2184) use the Fortnox preset letters (B/C/D/E/H/I/K/L/M) rather than the issue's list, apply only to new company_settings rows via column default, and reach existing companies through a fill-then-save action in settings: the presets already name those letters and keep Fortnox imports in one series, and a mid-year remap by migration would start new numbering with no actor in behandlingshistoriken (BFNAR 2013:2 p. 9.16). +[2026-09-06] Covering-set proposals (#2293, PR #2359) are computed at read time in listAccountItems through the batch form of the guard's own detector (detectExplainingVoucherSets), never persisted or auto-applied, and confirmed per row through the existing 1:N links door rather than added to runReconciliation's dry run as the issue proposed: the dry-run result and potential_journal_entry_id are 1:1-shaped, the bridge table is the surface that steers to Bokför, and one definition of "explained by the ledger" at the doors and in the view beats a lookalike search over the RPC's narrower notion of unlinked (it lacks the payment-row anchor). +[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen. +[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable". +[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler. diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index ff9814a3..97d48b72 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -43,6 +43,7 @@ import { type InvoiceListSort, type InvoiceListSortColumn, } from '@/lib/invoices/invoice-list-sort' +import { invoiceRowTone, type InvoiceRowTone } from '@/lib/invoices/invoice-list-row-tone' import { listContextKey, writeListContext } from '@/lib/navigation/list-context' import { ArrowDown, @@ -157,6 +158,21 @@ function statusGroupOf(invoice: Invoice): StatusGroup { return 'settled' } +// Row tone (#2215): the list must read without the status column. Settled +// rows recede to muted text, open rows keep the foreground, and the overdue +// chip stays the one marker on its row (one status indicator per element; +// semantic colours stay data-only, convention 12). This Record is the swap +// surface if the founder prefers colour: a row tint is +// 'bg-success/[0.04]' / 'bg-warning/[0.05]' / 'bg-destructive/[0.05]'; a +// left-edge bar is '[&>td:first-child]:border-l-2 [&>td:first-child]:border-l-success' +// and its warning/destructive siblings. Nothing else in the row changes. +const ROW_TONE_CLASS: Record = { + settled: 'text-muted-foreground', + open: '', + overdue: '', + none: '', +} + const TAB_LABEL_KEYS: Record = { all: 'tab_all', unpaid: 'tab_unpaid', @@ -1004,6 +1020,7 @@ export default function InvoicesPage() { { diff --git a/lib/invoices/__tests__/invoice-list-row-tone.test.ts b/lib/invoices/__tests__/invoice-list-row-tone.test.ts new file mode 100644 index 00000000..880a2396 --- /dev/null +++ b/lib/invoices/__tests__/invoice-list-row-tone.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { invoiceRowTone, type InvoiceRowToneInput } from '@/lib/invoices/invoice-list-row-tone' + +function row(status: InvoiceRowToneInput['status'], extra: Partial = {}): InvoiceRowToneInput { + return { status, credited_invoice_id: null, document_type: 'invoice', ...extra } +} + +describe('invoiceRowTone', () => { + it('settled: paid, cancelled and credited rows recede regardless of document type', () => { + expect(invoiceRowTone(row('paid'))).toBe('settled') + expect(invoiceRowTone(row('cancelled'))).toBe('settled') + expect(invoiceRowTone(row('credited'))).toBe('settled') + expect(invoiceRowTone(row('paid', { credited_invoice_id: 'inv-1' }))).toBe('settled') + expect(invoiceRowTone(row('cancelled', { document_type: 'quote' }))).toBe('settled') + }) + + it('open: a sent or partially paid customer invoice is waiting for money', () => { + expect(invoiceRowTone(row('sent'))).toBe('open') + expect(invoiceRowTone(row('partially_paid'))).toBe('open') + }) + + it('overdue: an overdue customer invoice', () => { + expect(invoiceRowTone(row('overdue'))).toBe('overdue') + }) + + it('none: drafts and non-invoice documents never read as receivables', () => { + expect(invoiceRowTone(row('draft'))).toBe('none') + expect(invoiceRowTone(row('sent', { document_type: 'proforma' }))).toBe('none') + expect(invoiceRowTone(row('sent', { document_type: 'quote' }))).toBe('none') + expect(invoiceRowTone(row('sent', { document_type: 'delivery_note' }))).toBe('none') + expect(invoiceRowTone(row('overdue', { document_type: 'proforma' }))).toBe('none') + }) + + it('none: an unpaid credit note is not a receivable even when sent or overdue', () => { + expect(invoiceRowTone(row('sent', { credited_invoice_id: 'inv-1' }))).toBe('none') + expect(invoiceRowTone(row('overdue', { credited_invoice_id: 'inv-1' }))).toBe('none') + }) + + it('treats a missing or null document_type as a customer invoice, like the list page', () => { + expect(invoiceRowTone({ status: 'sent', credited_invoice_id: null })).toBe('open') + expect(invoiceRowTone({ status: 'overdue', credited_invoice_id: null, document_type: null })).toBe('overdue') + }) +}) diff --git a/lib/invoices/invoice-list-row-tone.ts b/lib/invoices/invoice-list-row-tone.ts new file mode 100644 index 00000000..146b3b00 --- /dev/null +++ b/lib/invoices/invoice-list-row-tone.ts @@ -0,0 +1,34 @@ +import type { Invoice } from '@/types' + +/** + * Row tone for the customer invoice list (#2215): the list must read without + * the status column. Four tones, three of which the eye separates at a glance: + * + * - settled: the row is done (paid, cancelled, credited) and recedes + * - open: a customer invoice waiting for money (sent, partially paid) + * - overdue: an open customer invoice past its due date + * - none: everything else (drafts, quotes, proformas, delivery notes, unpaid + * credit notes); the status column already marks those with a chip + * + * The guards mirror matchesListTab in the list page: only a real customer + * invoice (document_type 'invoice', not a credit note) counts as open or + * overdue, so a sent proforma or quote never reads as a receivable. + */ +export type InvoiceRowTone = 'settled' | 'open' | 'overdue' | 'none' + +export type InvoiceRowToneInput = Pick & { + /** Older list rows can lack the column; the list treats a missing value as 'invoice'. */ + document_type?: Invoice['document_type'] | null +} + +const SETTLED_STATUSES: ReadonlySet = new Set(['paid', 'cancelled', 'credited']) + +export function invoiceRowTone(invoice: InvoiceRowToneInput): InvoiceRowTone { + if (SETTLED_STATUSES.has(invoice.status)) return 'settled' + const isCustomerInvoice = + (invoice.document_type || 'invoice') === 'invoice' && !invoice.credited_invoice_id + if (!isCustomerInvoice) return 'none' + if (invoice.status === 'overdue') return 'overdue' + if (invoice.status === 'sent' || invoice.status === 'partially_paid') return 'open' + return 'none' +}