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' +}