Commit Graph
3 Commits
Author SHA1 Message Date
MattssonandClaude Fable 5 cd344b6dbb fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries

check_balance_on_post only fires on the draft-to-posted UPDATE transition,
so any code path that INSERTs a row with status 'posted' directly skipped
balance validation entirely. The invariant sum(debit) = sum(credit) on
every posted entry was DB-enforced only for the engine's commit lifecycle.

Add check_balance_on_posted_insert, a deferred constraint trigger on
AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing
check_journal_entry_balance() function, which already handles the
journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics
let an atomic transaction insert header and lines together; zero-line and
unbalanced posted inserts are rejected at constraint evaluation. All
existing checks stay intact; this only adds coverage.

The one first-party posted-INSERT path outside an RPC, the sandbox seed,
now books through the bookkeeping engine (createJournalEntry) instead of
raw inserts. SIE import already inserts header and lines in a single
transaction via its structured RPC and passes unchanged.

pg tests cover the new path (zero-line rejected, unbalanced rejected at
SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and
existing posted-entry fixtures move to a transactional
insertPostedJournalEntry helper so they stay valid setup.

Fixes #327

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): insert list-filters pg fixtures in one transaction

The list-filters suite (landed via a sibling merge) inserted posted
headers with getPool().query, where each query autocommits: the deferred
check_balance_on_posted_insert constraint fired at the header's own
commit with zero lines and correctly rejected the fixture. Header and
balanced lines now share one BEGIN/COMMIT so the constraint evaluates
the complete entry, mirroring the insertPostedJournalEntry helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(seed): insert journal headers as drafts, post after lines land

check_balance_on_posted_insert (renamed to apply-time version
20260806130000) rejects a posted header whose transaction has no lines.
PostgREST autocommits each request, so every seed path that inserted
posted headers first would die with "has zero total": the sandbox seed
(ledger history, invoice vouchers, salary vouchers), seed-demo-account
and seed-export-data. All now insert draft headers, insert lines, then
flip to posted so check_balance_on_post validates the finished
verifikat. The sandbox seed keeps its documented no-events design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): preserve a preset committed_at on draft-to-posted transition

set_committed_at() stamped now() unconditionally, so the seed flows that
post backdated drafts lost their historical booking timestamps and every
demo verifikat read as booked today (CodeRabbit finding on PR 1439).
Stamp only when committed_at is NULL: the engine path (drafts carry no
committed_at) behaves exactly as before and a posted entry still always
has a committed_at; an explicitly supplied value now survives posting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): preserve preset committed_at only for trusted roles

The IS NULL guard alone (20260806150000, never shipped; replaced by
20260806160000) let any RLS-permitted member backdate committed_at
through PostgREST by presetting it on a draft and posting, which the
Swedish accounting review flagged: committed_at is what the BFL 5 kap
timeliness checks and behandlingshistorik treat as the genuine
transition time. Preset values now survive posting only for
service_role/postgres/supabase_admin; authenticated and anon writers
always get the now() stamp. Consequence: the sandbox seed (runs as the
requesting user) gets committed_at = posting time, accepted and
documented in the route; the demo scripts run as service_role and keep
their backdated history. pg tests cover all four paths, with the upper
timestamp bound CodeRabbit asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): restore superseded migration so the preview tracker stays consistent

The preview branch had already applied 20260806150000 when the previous
commit deleted the file, orphaning the preview's migration tracker
("Remote migration versions not found in local migrations directory").
Restored with a header explaining it is superseded in the same deploy by
20260806160000, so the unguarded semantics are never live on their own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): decide committed_at trust by JWT claims, not current_user

The Swedish review found the current_user guard bypassable:
commit_journal_entry is SECURITY DEFINER and granted to authenticated,
so inside it current_user is the function owner and a member could
preset a backdated committed_at on a direct-inserted draft and launder
it through the RPC. The guard now reads the JWT claims role (same
primitive as the RPC's own tenant guard): preset values survive only
for service_role or claim-less backend connections; authenticated and
anon callers are always stamped now(), on both the direct UPDATE and
the RPC path (new pg test). Both migration files now carry the
identical final body so no unguarded intermediate exists as a
standalone applyable unit. Behandlingshistorik logging of trusted
overrides is follow-up #1444.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:00:05 +02:00
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob WennbergandClaude Opus 4.8 f538401988 Invoice correctness bundle: voucher-link race, agent send guards, payment-reversal restore (audit C2/C17, F-2026080) (#666)
* fix(invoices): atomic link_invoice_to_voucher RPC — close the customer voucher-link race (audit C2)

linkInvoiceToVoucher() did UPDATE-then-INSERT with a manual rollback restoring a STALE pre-link snapshot: under concurrent linking on the same invoice, A's failed insert could overwrite B's successful link while B's payment row remained — corrupting paid_amount/AR. Mirrors the supplier-side link_supplier_invoice_to_voucher fix (PR #602).

- New SECURITY DEFINER RPC locks the invoice FOR UPDATE, re-validates (status, posted voucher, 151x AR credit, currency, overshoot, already-linked) and applies UPDATE + INSERT in one PG transaction. Inherits the supplier RPC's remaining-amount fix (trust stored remaining_amount even at 0 — the TS '> 0' guard let rounding drift slip past FULLY_PAID). Hardened per audit A5: REVOKE from PUBLIC/anon, GRANT to authenticated + service_role.
- linkInvoiceToVoucher() now delegates to the RPC — same signature, same LINK_VOUCHER_* codes, so all callers (route, pending-op executor, MCP) are unchanged. Keeps the invoice.paid event (now emitted with the post-link row, mirroring the supplier wrapper) and the best-effort bank auto-reconcile.
- pg-real tests: full/partial link, overshoot leaves the invoice untouched, ALREADY_LINKED, and the race regression (two concurrent full links -> exactly one wins, paid_amount never exceeds total, exactly one payment row). Verified locally against supabase/postgres:15.8.1.060 with all 334 migrations replayed: 10/10 pass. Two unrelated pg tests fail locally with AND without this change (pre-existing env sensitivity; green in CI).
- Unit tests re-mocked to the RPC-wrapper contract.

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

* fix(invoices): agent send path — block cancelled invoices + preflight PDF render (audit C17)

commitSendInvoice (the agent/MCP path) was missing two guards the send route has:

- No cancelled guard: a cancelled invoice passed the already-sent check, got re-rendered and EMAILED (a 'MAKULERAD' PDF delivered as if live), and the unguarded status flip silently re-activated it to 'sent'. Now rejected with the registry's INVOICE_SEND_CANCELLED message (400), mirroring the route.
- No preflight render: the executor assigned the F-series number BEFORE rendering, so a render failure left a numbered-but-never-issued invoice (an F-series gap if the draft is abandoned). Now mirrors the route: on fresh allocation, render with an 'F-PREVIEW' placeholder first and reject with INVOICE_SEND_PDF_RENDER_FAILED before any number is consumed; retries with an existing number skip the preflight.

Items/credit-note lookup moved above the preflight (it needs them); the real render and everything downstream are unchanged.

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

* fix(bookkeeping): payment reversal restores invoice state and releases bank line (F-2026080)

Reversing a payment voucher left the customer invoice deadlocked: status
stayed 'paid' while remaining_amount stayed stale (= total), and the bank
transaction kept pointing at the reversed JE so the line could neither be
re-matched nor deleted.

- Customer branch now recomputes remaining_amount from total (the supplier
  branch already did) and clamps paid_amount at 0.
- Both branches delete the payment row(s) tied to the reversed voucher so a
  re-match doesn't double-count or trip the unique indexes.
- New releaseLinkedTransactions() detaches bank transactions from the
  reversed JE (by journal_entry_id and by captured payment transaction ids),
  clearing the link/categorization columns so the line returns to the inbox.

Covers every standalone storno path (reverse route, MCP reverse tool,
delete-last-voucher); the match-invoice route already handled its own case.

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

* fix(transactions): match-invoice preview double-subtracted VAT on per-item path (F-2026080)

InvoiceItem.line_total is the NET line amount (it sums to invoice.subtotal,
each line's vat_amount = line_total * rate), but the preview's per-item rate
aggregation computed sub = line_total - vat_amount, double-subtracting VAT
and producing an unbalanced previewed verifikat (revenue credit too low
against the 1930 debit). The commit path (generatePerRateLines) was already
correct; only the preview disagreed.

Regression test mirrors the F-2026080 invoice: multi-item 25% SEK cash entry
must balance, with 3001 = subtotal and 2611 = vat_amount.

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

* fix(bookkeeping): address PR #666 review — supplier cash reversal, RPC tenant guard, CI fixes

Review feedback fixes:

- Supplier cash-payment reversal (Greptile): the supplier branch required a
  payment row before restoring status/amounts, so reversing a
  supplier_invoice_cash_payment (which books no payment row) left the invoice
  deadlocked at paid/remaining=0 — the same bug the customer branch fixed.
  Mirror the customer fallback (revert full paid_amount when no row exists).

- Payment-row lookups now filter by invoice id + company_id: a batch voucher
  (match_batch_allocate) carries one payment row per invoice under the same
  journal_entry_id, so the unfiltered .single() errored out and silently
  yielded null.

- Tenant guard on the voucher-link write RPCs (compliance V8.2.1, audit A5):
  link_invoice_to_voucher and link_supplier_invoice_to_voucher are SECURITY
  DEFINER + authenticated-executable, so any signed-in user could mutate
  another tenant's invoices via PostgREST. New migration applies the PR #625
  claims-based membership guard to both, caps p_notes at the Zod layer's
  2000 chars, and gives the supplier RPC the explicit REVOKE/GRANT it never
  had (was default PUBLIC execute). Covered by a new pg-real test.

- releaseLinkedTransactions now logs Supabase errors (compliance V16.1) —
  a failed release leaves a bank line stuck on a reversed JE and must be
  observable.

CI fixes:

- naive-ore-round ratchet (core-only): payment-sync.ts converted to
  roundOre() from @/lib/money (-4 occurrences vs baseline).
- match-batch-allocate.pg.test.ts flake (pg-real): Date.now()+random arrival
  numbers collided in CI; now time-component + monotonic counter.

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

* fix(bookkeeping): address PR #666 review round 2 — payment attribution, batch-scoped deletes, send guard

- RPC payment attribution (GDPR Art.32): user-session callers can no longer
  attribute invoice_payments / supplier_invoice_payments rows to an arbitrary
  user via p_user_id — the JWT sub is authoritative when role is
  anon/authenticated. service_role / direct callers keep p_user_id verbatim
  (their scoping happens in TS). pg-real test asserts the spoofed id is
  ignored.

- Payment-row deletes scoped to the source invoice (SOC 2 CC6.3): a batch
  voucher carries sibling payment rows for other invoices whose status this
  sync doesn't restore; deleting them desynced paid_amount from the rows.

- releaseLinkedTransactions success audit log: transactions has no
  write_audit_log trigger, so clearing the link/categorization columns now
  logs the affected transaction ids for incident reconstruction.

- commitSendInvoice guard extended with partially_paid/credited (ASVS V2.3):
  both imply the invoice was already issued; the status flip would have
  regressed them to 'sent'.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:32:39 +02:00