Commit Graph

546 Commits

Author SHA1 Message Date
Mattsson f10bac6023 fix(reports): block unsupported EUR annual reports (#1366)
Closes #1360
2026-08-03 15:34:32 +02:00
Mattsson d9fb5da16d fix(reports): localize latest voucher label (#1365)
Closes #1267
2026-08-03 15:18:21 +02:00
Mattsson 9e54a8e400 fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com>
2026-08-02 20:44:59 +02:00
Mattsson 18cdba3574 fix: make out-of-order SIE opening balances atomic (#1334)
* fix: preserve SIE IB on out-of-order imports

* fix: make SIE opening balance replacement atomic

* test: seed accounts for atomic IB pg coverage

* test: complete atomic IB pg fixtures

* fix(import): avoid IB resync across fiscal-year gaps

* test(import): mirror PostgREST date values in pg adapter

* fix(import): address opening balance review feedback
2026-08-02 20:39:29 +02:00
Mattsson d684e3c440 feat: add theme palettes (#1326)
Add Neutral, Indigo, Forest, and Sand palettes independently of Light, Dark, and System. Persist and hydrate the selection, add the accessible settings picker, and include the validated review fixes for keyboard navigation and Swedish copy.
2026-08-01 16:02:12 +02:00
Jakob Wennberg 4933fae7a9 fix(bookkeeping): correct seeded BAS account names that contradicted engine bookings (#1321)
- seed_chart_of_accounts named 7210 'Semesterlöner' while payroll books
  gross salaries there (BAS: 'Löner till tjänstemän'; vacation pay is
  7285), so every seeded AB showed salary costs under a vacation-pay
  label in Nyckeltal and every other report
- 3002 was named 'Försäljning varor 25%' although 3002 is the 12% revenue
  account everywhere else (invoice booking, category mapping,
  default_vat_rate seeding)
- 7010 and 3001 get their BAS 2026 names; 2631 loses a double space
- backfill renames existing rows only on exact seeded-literal match plus
  is_system_account, so accounts users renamed survive untouched
- account-descriptions.ts had the 7010/7210 names swapped;
  client-account-names.ts labeled 2510 (Skatteskulder) as 'Personalskatt'

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:58:27 +02:00
Jakob Wennberg 0f1c7c9365 fix(import): refuse a Bokio connection that opens a different company (#1315)
A Bokio integration token is scoped to one Bokio company and the company id is typed in by hand, so credentials for the user's other company imported that company's customers, suppliers and invoices with no error at all. Probe /companies/{id} before storing, mirroring the Bjorn Lunden /details probe, and refuse on a confident org-number mismatch.

Also surface the inbox mail body when nothing was attached: it was captured in email_body_text and never read back, which made Gmail's forwarding-confirmation mail unreadable and the forward impossible to complete.
2026-07-30 19:07:40 +02:00
Jakob Wennberg 27ae59040e fix(transactions): retire stale invoice match pointers when an invoice settles (#1313)
* fix(transactions): retire stale invoice match pointers when an invoice settles

potential_invoice_id / potential_supplier_invoice_id are write-once import
suggestions: nothing revisited them once written. With recurring same-amount
invoices, an earlier suggestion pointed transaction A at invoice X, X was then
paid off by transaction B, and A kept pointing at a fully paid invoice. The
match dialog computed its amount diff against that invoice's 0 kr
remaining_amount and reported a bogus partial payment, and the dead pointer
also blocked a fresh suggestion: both re-suggestion scans require the column
to be NULL.

Add one shared helper, clearSettledInvoiceSuggestions(), that nulls a settled
invoice's own suggestion column on every other transaction of the same
company, scoped by company_id and by that invoice id only, never widening to
the confirmed invoice_id / supplier_invoice_id links. It is best effort by
construction: every caller has already booked a payment verifikat, so a failed
cleanup logs and returns instead of failing the settle.

Wired into every path where an invoice reaches paid through a payment:
the dashboard and v1 match-invoice / match-supplier-invoice routes, the
dashboard and v1 mark-paid routes, settleInvoicePayment, the batch allocation
route (per fully settled allocation), linkInvoiceToVoucher and
linkSupplierInvoiceToVoucher, linkTransactionToJournalEntry, and the MCP
staged-operation executors for mark_invoice_paid and
match_transaction_invoice. Partial payments are deliberately left alone: a
partially paid invoice is still matchable. The v1 supplier match route also
clears its own row's hint, which it was missing next to its dashboard twin.

Read-time revalidation stays as the backstop for the paths not wired up here.
countSuggestedMatches now delegates to listSuggestedMatches, which already
revalidates candidates, so the worklist badge can no longer claim a number the
list refuses to render.

A data-only backfill migration retires the pointers already stranded in the
database. It touches no journal entry, verifikat or period-locked data, is
idempotent, and its status lists mirror lib/invoices/matchable-statuses.ts.

Fixes #1259

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

* fix(transactions): wire the MCP batch allocation into the settled-pointer cleanup

Review follow-up on the #1259 fix.

commitMatchBatchAllocate calls the same match_batch_allocate RPC as the
dashboard route, and gnubok_match_batch_allocate is a live staged MCP tool, so
an agent settling a samlingsbetalning reproduced the issue exactly: the RPC
nulls potential_invoice_id / potential_supplier_invoice_id only on the source
transaction, leaving every other transaction of the company pointing at an
invoice the batch just closed. The per-allocation loop moves into
clearSettledBatchAllocationSuggestions() so the HTTP route and the MCP executor
run the same code and cannot drift again, with a commit-path test pinning that
only the fully settled allocation is retired.

The enlarged badge scan is made safe. countSuggestedMatches now feeds up to 200
ids into listSuggestedMatches, past the 150 per .in() that countInboxDocuments
already chunks for, so the candidate lookups are chunked at IN_CLAUSE_CHUNK too
and their ids deduped. Both lookups now check .error: previously a 414, a 500 or
an RLS change produced empty maps, an empty list and a zero badge with nothing
logged. Every failure branch here logs companyId, matching the logAndZero
convention.

Also: restore the anchorSupplierInvoiceDocument doc comment above its own call
in the dashboard supplier-invoice mark-paid route (the #1259 block had been
inserted between them), and assert the transaction update payload in the v1
match-supplier-invoice test, which now covers the potential_supplier_invoice_id
null that the route was missing next to its dashboard twin.

Fixes #1259

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:52:24 +02:00
Jakob Wennberg 1a5d205bd6 fix(webhooks): derive the stuck-in_flight window from the cycle bound and charge the stall an attempt (#1311)
* fix(webhooks): derive the stuck-in_flight window from the cycle bound and charge the stall an attempt

recoverStuckInFlight re-armed any in_flight row older than 2x
REQUEST_TIMEOUT_MS (20 s), but a cron cycle claims 50 rows and attempts
them serially, stamping updated_at once at claim time. From row 3 onward
every row was past the threshold before its own attempt started, so each
cycle recovered and re-claimed the rows the previous cycle was still
working through: duplicate POSTs of the same X-Gnubok-Delivery, and a
terminal status decided by a race whose loser was swallowed by
enforce_webhook_delivery_immutability as a log.warn.

Both halves of #1257 are fixed:

1. The window is derived, not guessed. The attempt loop is now bounded by
   an explicit CYCLE_BUDGET_MS (120 s) instead of relying on the platform
   to kill it, and the sweep window is that bound plus one receiver
   timeout plus slack (160 s), floored at the cron's own batch size so
   the 5-row emit kick cannot re-arm rows the 50-row cron still owns.
   Each row is also re-stamped immediately before its own attempt, so a
   row's in_flight age measures the attempt rather than the claim. The
   same write doubles as an ownership check: a zero-row result means
   another cycle took the row, and the POST is dropped instead of
   duplicated.

2. The sweep charges an attempt, so MAX_ATTEMPTS is a real cap again.
   The predicate moves into a SECURITY DEFINER RPC because PostgREST can
   express neither `attempts = attempts + 1` nor the conditional flip at
   the cap, and a read-then-write loop would reopen a TOCTOU against the
   immutability trigger. A row recovered past the cap lands on exactly
   the terminal state the normal retry path produces: status 'dead',
   attempts = MAX_ATTEMPTS, error prefixed 'attempts_exhausted'. The
   trigger is neither weakened nor bypassed: the outer UPDATE keeps
   status = 'in_flight' in its own WHERE, so a row that raced to a
   terminal status fails re-evaluation under READ COMMITTED and is
   skipped rather than aborting the statement.

Rows the cycle claimed but will not reach are handed back as re-claimable
instead of being stranded in in_flight, without charging an attempt they
never made. Adds the partial index the sweep needs (idx_webhook_deliveries_due
is partial on pending/failed and structurally excludes in_flight).

No retention or pruning cron: webhook_deliveries still has no cleanup
path, which is a separate decision and stays a follow-up.

Fixes #1257

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

* fix(webhooks): back the cycle budget with maxDuration and give the stall the normal retry backoff

Review follow-up on the #1257 fix. Two of the findings were blocking and
compound each other: the fix made stranding likely and destructive at the
same time.

1. The 160 s sweep window was derived from CYCLE_BUDGET_MS, but nothing
   granted a dispatch cycle 120 s: the cron route declared no
   maxDuration. If the platform killed the invocation before the budget
   check fired, releaseUnattempted never ran and the claimed-but-
   unattempted rows stayed in in_flight carrying their claim-time
   updated_at, which is exactly the invariant the window depends on.
   The route now declares maxDuration = 300, the way the stripe
   transactions and documents verify crons pair a budget with one, and a
   route test asserts both the literal and its relation to
   CYCLE_BUDGET_MS. The kick path can never be given a maxDuration
   (after() runs inside an arbitrary route), so dispatch-kick.ts now
   states why it does not need one: KICK_BATCH_SIZE x REQUEST_TIMEOUT_MS
   is 50 s, so the dispatcher's budget check never fires there.

2. The sweep charged an attempt but re-armed at p_now, i.e. no backoff,
   while the normal failure path waits RETRY_BACKOFF_SECONDS. A row that
   kept getting stranded (deploy, instance recycle, any cycle that
   outlives its invocation) was re-claimable on the next per-minute tick
   and could burn all 8 attempts in roughly 20 minutes, landing in the
   terminal, immutable 'dead' state without its receiver ever being
   contacted. Pre-fix that loop was infinite but harmless, so this was a
   net-new way to lose a delivery. recover_stuck_webhook_deliveries now
   takes p_backoff int[] (RETRY_BACKOFF_SECONDS, still single-sourced in
   TS) and sets next_attempt_at with the same clamped index lookup
   markFailedForRetry uses, so a stall costs an attempt AND the same wait
   a 500 costs. A non-positive or empty schedule is rejected rather than
   silently degrading to p_now. The migration has not been applied to any
   deployed environment, so it is amended in place rather than superseded;
   it drops the old 3-argument signature so no ambiguous overload can
   survive in a dev or CI database.

Also from the review:

- stuckInFlightAfterMs(batchSize) was dead code whose Math.min clamp made
  every input return 120_000, so the documented DEFAULT_BATCH_SIZE floor
  never fired and the test that pinned it (stuckInFlightAfterMs(5) ===
  stuckInFlightAfterMs(50)) was a tautology. It is now the plain constant
  STUCK_IN_FLIGHT_AFTER_MS with a comment that credits the budget, and
  the test drives the window through dispatchDueDeliveries at batch sizes
  5, 50 and 500, which fails if the window ever becomes batch-derived
  again.
- The sweep's outcome reaches the operator: recovered / recoveredDead are
  on DispatchSummary and in the cron's structured log, so a tick that
  takes deliveries terminal is visible without grepping helper-level warn
  lines.
- releaseUnattempted no longer writes 'failed' onto a never-attempted
  row. claim_due_webhook_deliveries does not return the pre-claim status,
  but it does return attempts, and every path that writes 'failed' also
  writes attempts >= 1, so attempts = 0 identifies a row that was
  'pending' and it is restored as such. webhook_deliveries is
  customer-visible behandlingshistorik; a delivery that was claimed and
  handed back without a single POST must not read as a failure there.

The two deferred hygiene items (no retention path for webhook_deliveries,
and the sweep still being an unbounded tenant-global UPDATE) are reported
as a comment on #1257 and noted in the migration.

Fixes #1257

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:51:41 +02:00
Jakob Wennberg f3bf50d862 fix(invoices): roll back the header row when a recurring-schedule item replace fails (#1312)
* fix(invoices): roll back the header row when a recurring-schedule item replace fails

PATCH /api/invoices/recurring/[id] and the update_recurring_schedule commit
executor wrote the schedule header first, then replaced the items. An item
insert failure restored the items snapshot but left the header update
committed, so a combined edit half-applied: a new day_of_month or
default_dimensions stayed while the line edit was undone.

Both write paths now go through one shared helper,
lib/invoices/apply-recurring-schedule-update.ts, which snapshots the header
before writing it (only for a combined edit, the only case with something to
undo) and compensates it on any items failure. The rollback update is filtered
on the updated_at stamp our own write produced, so a concurrent writer (the
hourly cron, a second edit) wins instead of being clobbered from a stale
snapshot: audit finding C2 in lib/invoices/voucher-matching.ts.

A compensation that itself fails is no longer swallowed. The helper reports
itemsRestored / headerRestored, logs the unrecoverable rows and the intended
restore payload, and both call sites then return the new
INVOICE_RECURRING_UPDATE_PARTIAL registry entry, which tells the user in
Swedish that the schedule may be half-saved and to check fields and items
before retrying. A clean rollback keeps the PG-mapped error so a CHECK
violation still surfaces its specific message.

Also in the rewritten block:
- the items DELETE error is checked, so a failed delete no longer proceeds to
  an insert that would duplicate every line;
- the 404 existence check moved above every write, so a PATCH with items for a
  missing or cross-tenant id writes nothing;
- the items snapshot uses select('*') with id/created_at stripped on restore
  (same idiom as replaceInvoiceItems), so a column added later is carried
  through instead of silently dropped;
- NewRecurringScheduleDialog unwraps the nested { error: { message } } envelope
  the route returns, which otherwise reached the toast as "[object Object]".

The cron's no-empty-items invariant holds on every failure path: the items are
either untouched, restored, or the failure is reported explicitly.

Fixes #1275

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

* fix(invoices): never write when the compensating snapshot is unavailable

Follow-up on the recurring-schedule rollback: the helper still performed two
writes it already knew it could not compensate.

- The header snapshot read now checks its error and a missing row, and the
  header UPDATE is skipped entirely when either holds, so no header change is
  committed that we already know can never be rolled back.
- An unreadable item snapshot now aborts BEFORE the delete (rolling the header
  back) instead of deleting first and reporting itemsRestored: false, so the
  cron invariant "a schedule always has items" holds on every failure path.
- That header read now runs whenever items are replaced and is scoped by
  company_id, so it doubles as the ownership proof the schedule_id-only item
  delete/insert lacks (the commit executor runs with RLS off). Stated in the
  JSDoc as well.
- The item snapshot is paginated via fetchAllRows: a schedule with more than
  1000 lines could otherwise restore partially while reporting a clean
  rollback.
- The executor now returns errorCode INVOICE_RECURRING_UPDATE_PARTIAL,
  surfaced as CommitResult.code and persisted as result_data.error_code, so a
  staged-op caller can detect the partial state without substring-matching the
  Swedish sentence.
- Route: details keys are camelCase throughout, and an item failure is logged
  once, with the repair context kept on the partial path only.

Tests: the unreadable-snapshot branches are exercised (including the
previously unused itemsSnapshotError harness hook), and the test that pinned
"header written with no possibility of rollback" now asserts that nothing is
written at all.

Fixes #1275

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:50:56 +02:00
Jakob Wennberg 4a38fa30ed fix(recon): share the cash-account scope and cover the no-1930 case (#1309)
PR #1295 (144cc514) fixed computeVatCloseCheck on main while this branch was
fixing it a second, different way. This rebuilds the branch on top of that
merge instead of re-landing the duplicate: main's local
getScopedReconciliationStatus stays as the MCP entry point, its lookup body
moves into lib/reconciliation/cash-account-scope.ts, and the pieces main does
not have are added on top.

Why the lookup has to live in lib/: the bokslut readiness aggregator has the
same defect and is core code, which must never import from @/extensions/. It
called getReconciliationStatus with 4 positional args, so cashAccountId stayed
undefined and scopeTransactionsToAccount fell through to its currency-only
filter: the bank side summed every SEK cash account while the GL side stayed on
1930, and the wizard reported "Bankavstamningen visar en differens" with zero
unmatched transactions and zero unmatched GL lines to point at. Same shape as
the MCP blocker in #1290, different surface.

Decisions taken deliberately, not by taking 'ours':

1. Lookup errors fail CLOSED, everywhere. resolveCashAccountScope throws
   "Kunde inte hamta kassakonto <n>" instead of returning the unscoped
   fallback. The earlier version on this branch logged and fell back, which
   turned a transient DB error or an RLS denial straight back into the #1290
   pooling path. Main's contract wins; its merged test asserting exactly that
   still passes untouched.

2. The default resolution no longer hard-codes 1930. With no account_number
   argument the resolver tries 1930 and, only if the company has no such row,
   falls back to its primary cash account. Measured read-only on prod
   2026-07-30: 2 companies have no 1930 cash_accounts row while running two SEK
   cash accounts each and zero journal_entry_lines on 1930, so the check
   compared their entire SEK bank volume against an empty GL side, i.e. a
   high-severity bank_unreconciled blocker with count 0 that no user action
   could clear. Roughly 20x the difference the issue reported. A caller that
   NAMES an account gets no fallback, so gnubok_get_reconciliation_status still
   rejects "Okant kassakonto 9999" rather than silently answering about a
   different account. 1367 companies have a 1930 row and are unaffected,
   including the 5 whose 1930 row is not the primary one.

3. The blocker message names the resolved account instead of a literal 1930:
   pointing a user at 1930 when the reconciliation ran on 1935 sends them to an
   account with no lines on it.

4. warnIfUnscopedAcrossCashAccounts logs a warning when a run left
   cashAccountId undefined AND the rows it fetched really do span more than one
   cash account. Kept on the write path too: an unscoped runReconciliation can
   persist a wrong journal_entry_id, which does not clear itself later.

Duplicate regression suite collapsed: the branch's
vat-close-check-bank-scope.test.ts overlapped main's
vat-close-check-reconciliation-scope.test.ts case for case, so only the cases
main lacked were merged into main's file (the primary-account fallback, the
message naming the resolved account, the blocker still firing on a genuine
scoped difference, and the tool handler's own scope resolution).

Residuals are now tracked issues, not code comments:

- #1298: the post-sync runReconciliation sweeps in
  app/api/extensions/enable-banking/sync/cron/route.ts and
  extensions/general/enable-banking/index.ts still run unscoped. They are write
  paths.
- #1299: booked transactions with a NULL cash_account_id whose verifikat has no
  line on the primary account still inflate the bank total. Measured all-time
  on prod: 294 booked NULL rows, 22 of them on verifikat with no 1930 line,
  4 companies, net -4170.31 kr with monthly swings from -18055.82 kr to
  +38086.00 kr. Fixing it means finishing the cash_account_id backfill.

Also: the file-global logger mock in bank-reconciliation.test.ts now wraps the
real module and swaps only warn, instead of substituting a four-method stub
whose child() returned undefined for the entire module graph of that suite.

Verified: npx vitest run over lib/reconciliation, lib/bokslut,
extensions/general/mcp-server, app/api/reconciliation, app/api/extensions,
app/api/v1, app/api/bookkeeping, app/api/transactions, lib/pending-operations,
lib/bookkeeping, lib/invoices, lib/transactions, lib/reports: all green.
eslint on the 8 changed files: 0 errors (18 pre-existing unused-import
warnings in server.ts). tsc --noEmit: 405 errors, byte-identical to the
origin/main baseline. check:guards passes. No migration, so no pg-real test.

Fixes #1290

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:50:19 +02:00
Jakob Wennberg 9bb8b40420 fix(vat): veto the ruta 05 rate fallback on a contradicting account label (#1310)
* fix(vat): veto the ruta 05 rate fallback on a contradicting account label

The read-path fallback for #1289 shipped in #1296: fetchDynamicRuta05Accounts
drops the SQL sats filter and inferDomesticSalesRate resolves a NULL
default_vat_rate from the 30x1/30x2/30x3 suffix plus a matching
"25/12/6 % moms" label. That half is already on main and is not touched here.

What it lacks is a veto. Both signals can agree while the rest of the label
says the konto is not domestic taxable sales at all: "Forsaljning
byggtjanster 25 % moms, omvand betalningsskyldighet" is ruta 41, a VMB konto
is ruta 07, an export konto is ruta 36, a momsfri konto is ruta 42. Inferring
0.25 for any of them files the amount in ruta 05, which is a wrong box rather
than a missing one. A contradicting term now stands the fallback down, so the
konto keeps the behaviour it has today (omission) instead of being misfiled.
An explicitly configured rate, including an explicit 0, never reaches this
check and stays authoritative.

Two existing rules are now pinned by tests as deliberate, since a looser
resolver was proposed and rejected: the label must say the word "moms" after
the percent ("Forsaljning konsult 25 %" is a rate of pay, not a sats), and a
label naming two different sats resolves to nothing rather than to whichever
it spells out first.

Fixes #1289

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

* fix(vat): anchor the 0 % veto so it stops matching 10/20/30/100 %

The CONTRADICTING_ACCOUNT_NAME alternative `0\s*%` had no left word
boundary, so it also matched the trailing zero of any percentage ending
in zero. An ordinary domestic sales konto named "Forsaljning varor 25 %
moms, rabatt 30 %" was vetoed: agreeing 30x1 suffix, agreeing "25 %
moms" label, none of the veto's documented cases (momsfri, omvand, VMB,
export) applying. It dropped out of the ledger fetch, ruta 05 came out
short, and runVatDeclarationChecks raised a blocking
OUTPUT_VAT_WITHOUT_SALES_BASE: the exact #1261 symptom this module
exists to remove, re-created by the veto.

`\b0\s*%` keeps a genuine "0 % moms" label vetoing and stops matching
inside a longer number. The comment now records why the boundaries
differ per alternative, since the asymmetry is deliberate: "vmb" needs
both, "export" and "utanfor" are bare substrings so Swedish compounds
are caught too.

Both directions are pinned by tests that fail without the anchor.

Fixes #1289

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:49:39 +02:00
Jakob Wennberg fa394e3759 fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297)
Four fixes from the exit mail Anders Orback (Center Node AB) sent hours
after churning. His five points were mostly one job: reconciling
skattekontot against banken before årsredovisningen.

Skattekonto look-alike rows. Skatteverket splits a retroactive
omprövningsbeslut across every month it re-charges and sends one
transaction per month, sharing date, text and amount; only
ranteberakningsdatum separates them, and we stored it but rendered it
nowhere. A real company posted 15 such vouchers (67 785 kr across Feb
2025-Apr 2026) unable to tell them from duplicates of the automatic
hämtning. Surface the field when it carries information: its month
differs from the Datum column, or another row in the same band is
otherwise indistinguishable.

The list-to-voucher round trip. The verifikat list collapsed to a
skeleton on every refetch and sprang back, moving rows under the
pointer; only the first load shows a skeleton now. Filter state is
React-only, so leaving the list loses it: add a hover-revealed
open-in-new-tab affordance on the voucher list and the skattekonto page,
where the link had been behind a hand-rolled opacity-0 that coarse
pointers never trigger.

Makulerad rendering. A stornoed verifikat now reads as struck out, per
data cell rather than on the row, because text-decoration propagates and
a child cannot opt out.

Vouchers-per-account discoverability. /reports/huvudbok?account=1930
already existed; the palette matcher requires every token and the entry
never contained the word "verifikat". Add ReportDescriptor.searchTerms
plus a report-library search box.

Also fixes a false "Saknar underlag" compliance chip that flashed before
attachment counts resolved, and a keyboard-access regression where
HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a
non-focusable wrapper from keyboard users.

No migration. No write paths, storno paths or posted entries touched.

Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308.
Open decision: #1305 (Omförd vs Makulerad).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:27:29 +02:00
Mattsson 6318501b71 fix(vat): recover ruta 05 for null-rate custom accounts (#1296) 2026-07-30 11:28:50 +02:00
Mattsson 392e847c1e fix(transactions): block invalid invoice match targets (#1294)
Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage.

Fixes #1260
2026-07-30 11:20:08 +02:00
Mattsson 17a7a62ceb fix(reports): stop the resultatavslut zeroing declarations, and make the mistake uninventable (#1293)
* fix(settings): explain why account deletion is blocked

The delete-account button was disabled while the user still owned
companies, but the reason only lived behind the "?" on the blocker row,
so the greyed-out button read as broken. Surface it as one visible attn
sentence directly under the button, and point aria-describedby at it
whenever the button is disabled, not only on a load error.

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

* feat(enable-banking): share one PSD2 consent across a user's companies

Connecting the same bank for a second company required a second BankID, and
at SEB that new authorization silently revoked the first one. A user with four
companies at one bank therefore signed four times a quarter and ended up with
three dead feeds, each still rendering as "Aktiv" with a stale last_synced_at
until someone pressed Synka.

Prod says this is not one customer: every SEB customer holding connections in
more than one company has had an earlier company stop syncing at the moment
the next was authorized, most of them while the consent was still formally
valid for weeks. The same measurement over other banks is far quieter, so the
one-active-session-per-PSU limit is real and ASPSP-side.

Enable Banking already supports the shape we want. POST /auth carries no
account restriction, so a session covers every account the user ticked at the
bank, and GET /accounts/{uid}/transactions takes no session id, so a second
company can sync its own accounts from an existing session. bank_connections
has no unique constraint on session_id, so this needs no migration.

Adds lib/session-sharing.ts plus GET /reusable-sessions and POST /attach. When
a live session in another of the user's companies still exposes accounts no
company syncs, the settings panel offers to reuse it: the new row shares
session_id and consent_expires, carries only the unclaimed accounts, and lands
in pending_selection so the existing IBAN-aware account picker does the ledger
mapping. Only the consent is shared; accounts, cash_accounts and transactions
stay strictly per-company.

Sharing a session changes three lifecycle paths, all handled here:

- Disconnect and reconnect now refcount before revoking. A blind revoke would
  take down a sibling company's feed, which is the exact failure this removes.
  The count runs on a service-role client because RLS hides a sibling in a
  company the user has since left, and it fails closed: an uncertain count is
  treated as shared, since a lingering consent lapses on its own in 90 days
  while a wrongly revoked one kills a working feed.
- A renewed consent fans out to every company sharing the old session, and
  re-points their account uids by IBAN. Several ASPSPs reissue uids on
  re-authorization, so carrying the session id alone would have left siblings
  calling retired uids and re-broken them every quarter. This is also why the
  superseded session_id is no longer nulled at /connect: the callback needs it.
- The nightly probe runs once per distinct session and applies the verdict to
  every row holding it, and expiry mails are keyed per (user, session), so one
  dead consent is one probe and one mail rather than four of each.

Only enabled cash_accounts rows count as claiming an IBAN. The callback mirrors
every account in a consent, deselected ones included, so counting any row as a
claim would leave nothing offerable once the first company connects.

An account handed to a company also stops being offered while that company's
picker is still open, closing the window where two companies could book the
same physical account.

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

* fix(ink2): read the resultaträkning from the pre-closing books

INK2R summed journal entries raw, so it included the resultatavslut that
zeroes every P&L account into 2099 at year-end. Nettoomsättning, kostnader,
periodiseringsfond and skatt all came out as 0, which cascaded into INK2S
7650/7651 and the taxable result. INK2 is always filed after bokslut, so
this was every real declaration, and nothing warned: with the P&L at zero
the balance sheet still tied out.

INK2R now reads two views of the same period. The balance sheet comes from
the closed books so 7302 keeps arets resultat via 2099; the income statement
comes from the pre-closing books via excludeFinalClosingEntry, which drops
only fiscal_periods.closing_entry_id so skatt and bokslutsdispositioner stay
on the form (7525, 7528). The equity adjustment is now conditional on a
posted closing entry having moved the result into 2099.

Second, independent bug: accounts were mapped by BAS number with no regard
for the sign of the balance, so konto 1630 with a credit was reported as a
negative fordran instead of a skatteskuld and konto 2641 with a debit was
netted off the liabilities. The three sign-reclassification rules the K2
iXBRL mapper already had are extracted to lib/reports/sign-reclassification
.ts and applied to INK2R too, so both statutory reports present the same
balance sheet. Only the rule table is shared: k2-mapper keeps its sumOre
arithmetic because the iXBRL path is ore-exact while INK2R truncates per
SFL 22:1.

NE-bilaga had the same empty-resultatrakning bug and gets the same fix.

Adds the closed-period coverage that was missing: the old tests only
exercised the mapping table against an open period, the one state in which
the engine happened to work.

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

* fix(reports): make the year-end closing decision explicit at every call site

generateTrialBalance took two optional booleans, so a caller that never
thought about the resultatavslut silently got 'include'. That is the wrong
default for anything summing class 3-8: the closing verifikat posts the
mirror image of every P&L account into 2099 inside the same period, so the
report reads ZERO across the board while the balance sheet still ties out
and nothing warns.

The booleans are replaced by a required
closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end'
with no default, so the build fails until each call site decides. All 40
were audited individually; every one keeps its current behaviour except
the two that were provably broken:

  - Resultatrapport read zero on every line for a closed year, in JSON,
    PDF and XLSX, and its prior-year comparison column read zero for
    anyone whose previous year was closed.
  - Resultat per projekt (dimension-pnl) had the same defect and must
    stay in lockstep with Resultatrapport to keep reconciling.

Both now pass 'exclude-all-year-end', which keeps them agreeing with the
formal Resultaträkning rather than pre-empting Stage 2 of #1051
(DECISIONS.md:632).

Deliberately unchanged and recorded in DECISIONS.md: the KPI expense
composition, which is blank for a closed year but cannot be fixed without
a migration and a displayed-figure change, and getBookedBolagsskatt, whose
contract is an open period and whose call chain already caused a
too-high-tax customer bug once.

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

* fix(vat): keep the resultatavslut out of the momsdeklaration

The closing verifikat posts the mirror image of every P&L account into
2099 inside the same fiscal period. Revenue accounts drive rutor 05, 39
and 40, so any VAT period containing the fiscal-year end reported NEGATED
turnover once the year was closed. get_vat_declaration_totals already
excluded vat_settlement and opening_balance entries, but not this one.

Reproduced read-only against production: for December of a closed year
the December declaration reported ruta 39 = -794 734 kr. After the fix
that period reports 0 and the January period carrying the real sale is
unchanged at 794 734 kr.

Keyed on fiscal_periods.closing_entry_id, not source_type = 'year_end':
avskrivningar, periodiseringsfond and skatt share that source_type and
must keep whatever VAT effect they carry. A reversed closing entry is
retained together with its storno so the pair still nets to zero, the
same predicate trial-balance.ts uses for closingEntry: 'exclude-final'.

Migration applied to the staging branch only; prod gets it via merge.
The pg test is written but has NOT been executed locally (no DATABASE_URL
configured and no local Postgres), so CI is its first real run.

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

* fix(kpi): keep the resultatavslut off the monthly chart

The monthly income/expense chart summed every posted entry in the fiscal
period. The closing verifikat posts the mirror image of every P&L account,
so once a year was closed the fiscal-year-end month charted the whole
year's revenue as negative income.

Measured read-only on production: 28 companies across 34 month-rows. The
worst case charted December income as -10 347 459,81 kr where the real
figure is +12,88 kr. Other examples: -1 868 731 -> +128 730,
-1 850 501 -> +431 709.

Both paths are fixed together so they keep agreeing: the RPC's monthly
section now joins the tb_ex_ye_entries CTE it already computes for
tb_ex_year_end, and monthly-breakdown.ts (the dimension-filtered fallback
and the MCP path) gains the matching source_type filter plus the
storno/correction chain of REVERSED year-end entries, so an undone bokslut
does not leave half a pair behind.

Migration 20260723180000 had recorded the omission as deliberate, on the
grounds that it mirrored the JS scan. It did, but the JS scan was wrong.

Migration applied to the staging branch (function body identical; three
comment lines differ from the committed file). Prod gets the file via merge.

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

* test(reports): pin every statement generator against a closed fiscal year

The per-generator suites all exercised an OPEN fiscal period, which is the
one state in which a generator that forgets the resultatavslut happens to
work. Declarations are filed AFTER bokslut, so the untested state was the
only state that occurs in production. That is why the same defect could
ship three times.

Two new suites over one shared fixture (closed-year-fixture.ts, a synthetic
closed AB with a resultatavslut, a credit 1630 and a debit 2641):

  closed-year-statements.test.ts enumerates the generators and asserts each
  reports the year's revenue rather than zero, plus its own bottom line. The
  table IS the checklist: a new report either appears in it or nothing stops
  it shipping with this bug. Verified by regressing income-statement back to
  closingEntry 'include', which fails 2 of its assertions.

  cross-surface-agreement.test.ts asserts the surfaces agree with each
  other, which is what every customer complaint actually was. INK2R and the
  K2 årsredovisning must produce the same årets resultat, the same fritt
  eget kapital, the same sign reclassifications and the same balance total.
  The operational family (Resultaträkning, Resultatrapport) must agree
  internally, and the gap BETWEEN the families is asserted explicitly as
  bokslutsdispositioner + skatt, so when Stage 2 of #1051 lands the test
  names the expectation to change instead of failing vaguely.

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

* chore(guards): ratchet against new reports that scan the ledger directly

A statement generator that aggregates journal_entry_lines itself has to
remember, on its own, that the resultatavslut posts the mirror image of
every P&L account into 2099 inside the same fiscal period. Three forgot,
and each read ZERO revenue for a closed year while the balance sheet still
tied out, so nothing warned.

generateTrialBalance now requires an explicit closingEntry mode, which makes
that decision a compile error. This guard is what keeps NEW reports on that
path: any generator under lib/reports or lib/bokslut that reads
journal_entry_lines and is not in the baseline set fails CI. Verified by
adding a throwaway report, which the guard rejects by name.

Voucher and line listings (general-ledger, journal-register, SIE export,
reconciliation, diagnostics) are sanctioned: they show the ledger as posted
and have no closingEntry decision to make.

Four existing lib/bokslut files are grandfathered rather than migrated. One
of them is a genuine open follow-up recorded in DECISIONS.md:
sarskild-loneskatt-calculator sums 7410-7419 with no year-end exclusion, so
its basis reads ~0 if it runs against an already-closed period. Left alone
deliberately: it is a tax figure whose call chain has caused a customer bug
before and deserves its own verified change.

Also ratchets naive-ore-round down 646 -> 641.

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

* test(reports): pin where sign reclassification applies, in both directions

No behaviour change. The sweep asked whether the 1630/2641 sign
reclassification should be extended to the remaining balance-sheet
surfaces; the answer is that there are none left.

Both STATUTORY presentations already have it: the K2 iXBRL årsredovisning
since 2026-07-23 and INK2R since 2026-07-29. The other two balance-sheet
surfaces must NOT have it: /rapporter Balansräkning and Balansrapport are
organised by account number under BAS-prefix headings, and balansrapport
documents an invariant that depends on every row staying debit-positive
where it was booked. Moving konto 1630 into a liability section would break
the add-the-rows-to-verify-the-balance property and hide the account from
anyone looking it up by number.

Asserting both halves is the point. The first half stops the
reclassification silently disappearing from one statutory surface again,
which is how a customer ended up comparing two of our own reports against
each other. The second half stops a future sweep "fixing" the operational
reports into disagreeing with their own documented contract.

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

* feat(reports): detect statement disagreement instead of waiting for a customer

Every year-end problem reported so far was a DISAGREEMENT between two of
our own screens, not a single wrong screen. The årsredovisning said one
figure, INK2 said another, and the customer did the reconciliation for us.
Nothing in the product noticed, because each screen tied out on its own.

Two additions:

  INK2R self-checks. On a closed year it compares the årets resultat it is
  about to declare against the booked konto 2099, and warns in Swedish when
  they disagree. This is the alarm that was missing: when INK2R reported
  0 kr against a booked 469 542 kr, the balance sheet still balanced, so no
  warning fired. Mirrors the equivalent check k2-mapper has had since
  2026-07-23, so both statutory reports now catch the same fault.

  reconcileStatements + GET /api/reports/statement-reconciliation return
  årets resultat from every surface side by side, grouped into families.
  ledger + statutory must agree and a mismatch is named; operational
  legitimately differs by bokslutsdispositioner + skatt until Stage 2 of
  #1051 lands, so that gap is explained rather than flagged.

The visual panel is deliberately not built here: it needs a
/frontend-design pass against the locked concept conventions plus sv/en
strings, and the warning above already puts the alarm where the user looks.

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

* fix(reports): address review findings from PR #1293

pg-real (7 failures, one signature): the new fixture called
insertFiscalPeriod({ isClosed: true }) and then inserted journal entries
into it, so enforce_period_lock (migration 017, legally required) refused
the write. Not worked around: the RPC's predicate keys on
fiscal_periods.closing_entry_id and never reads is_closed, so the fixture
now links the closing entry and leaves the period open, which exercises the
path that actually matters.

CodeRabbit, closed-year-fixture: EX_YEAR_END_ROWS dropped only the P&L legs
of the year_end entries (8811, 8910) and left their balance-sheet legs
(2125, 2512) at pre-closing values, so the 'exclude-all-year-end' view sat
160 000 kr out of balance and misrepresented what generateTrialBalance
returns. Latent, because today's consumers read class 3-8 only, but a shared
fixture that does not balance is a trap for the next consumer. Both legs now
go, and a new test asserts all three views sum to zero.

CodeRabbit, INK2 totals: renamed totals.resultAfterFinancial to
aretsResultat. It holds the result after bokslutsdispositioner AND skatt,
which is årets resultat, not resultat efter finansiella poster, and
build-data.ts uses the old name correctly for the different subtotal. The UI
already labelled the value "Årets resultat", so the name was simply wrong.

CodeRabbit, statement-reconciliation: the statutory branch called a
generator and caught any throw as "wrong entity type", mapping genuine
failures to a null figure that the comparison then skipped, so a real bug in
a declaration generator made the function report isReconciled: true. That is
the opposite of its purpose. It now dispatches on entity_type and surfaces a
generation failure as a named disagreement.

CodeRabbit, enable-banking (Emil's call to include): fetchClaimedIbans
returned an empty Set on a cash_accounts read failure, which is
indistinguishable from "nothing is claimed" and made every IBAN in the
session offerable, including accounts another company already books to. Its
own comment said it failed closed and its log said "offering nothing"; it
failed open. Returns null now, and findReusableSessions offers nothing when
the claimed set is unavailable. The test that pinned the fail-open asserted
toHaveLength(1) under the name "offers nothing"; it now asserts []. Also
removed an em dash per CLAUDE.md.

The remaining enable-banking finding (consent-expiry cooldown stamped only
on the selected connection, so it leaks one duplicate mail per sibling
company) is deliberately left to Emil: it changes email-sending behaviour in
his feature rather than fixing a stated contract.

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

* fix(reports): resolve second-round review findings on PR #1293

pg-real, two NEW signatures (the closed-period one from cycle 1 is gone):

kpi-report-aggregates-rpc.pg.test.ts asserted the exact contract migration
20260730090000 deliberately changes. Its comment read "year_end entries are
NOT excluded from monthly" and expected December expenses 1250. That fixture's
December holds only year-end-chain entries, so with the fix the month drops
out of the chart entirely, which is the correct operational view: a month
whose only activity is bokslut has no operating result. Assertion and file
docstring updated to the new contract rather than the test being removed.

vat-totals-closing-entry.pg.test.ts passed the wrong account arrays. p_net_
accounts is VAT_SETTLEMENT_NET_ACCOUNTS (2650/1650, the momsredovisning
settlement pair), not the output-VAT accounts. Putting 2611 there made the
extra year_end entry match the settlement-SHAPE detector, so an ordinary
sale-with-VAT was classified a momsredovisning and dropped, and the test read
0 instead of 10 000. The RPC was right; the fixture was not.

CodeRabbit, statement-reconciliation: resolveEntityType checked neither
query's error, so a genuine DB failure (RLS, permissions, connectivity)
returned null indistinguishably from "no entity type set", fell into the
unsupported-form branch and reported isReconciled: true. That is the same
silent-false-reconciled bug the cycle-1 refactor closed, one level down. The
companies error now throws; a missing company_settings ROW stays tolerated,
because .single() errors on zero rows and many companies have none. Mirrors
the pattern the INK2 and NE engines already use.

Still open by Emil's explicit choice: the consent-expiry cooldown is stamped
only on the connection it was handed, so it leaks one duplicate mail per
sibling company on the shared session. That changes email-sending behaviour
in his feature rather than fixing a stated contract, so it stays his.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:03:05 +02:00
Jakob Wennberg 198d3092c7 fix: counterparty template pick crashes the page (#1291)
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.

Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).

Five more page-crashes of the same shape, adversarially verified:

- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
  a toast description. The Toaster is a sibling of {children} in the ROOT
  layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
  at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
  where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
  prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
  workspace was in the error boundary for every company that had opened it.

Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.

Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.

No migrations.
2026-07-29 19:20:25 +02:00
Jakob Wennberg ef25a87d75 feat(mcp): Tasks extension (io.modelcontextprotocol/tasks) (#1283)
* feat(mcp): speak spec revision 2026-07-28 (stateless core)

Adopt the 2026-07-28 MCP spec revision on the connector endpoint while
keeping every handshake-era client (2025-06-18 and earlier) byte-identical:

- Accept per-request _meta protocol negotiation
  (io.modelcontextprotocol/protocolVersion); unsupported versions return
  UnsupportedProtocolVersionError (-32022) with the supported list.
- Implement server/discover (spec MUST): supported revisions, capabilities
  including the extensions field, identity, instructions, freshness hints.
- Decorate results for stateless clients: required resultType, serverInfo
  in _meta, and CacheableResult ttlMs/cacheScope on tools/list,
  prompts/list, resources/list, resources/read.
- Validate the standard Mcp-Method/Mcp-Name request headers when present
  (HeaderMismatchError -32020); absence stays accepted.
- Declare the ratified MCP Apps extension (io.modelcontextprotocol/ui) in
  capabilities; the widgets already use the ratified mime type and
  _meta.ui.resourceUri shape, so no widget changes are needed.
- OAuth: include the RFC 9207 iss parameter on every authorization
  response (success and error) and advertise
  authorization_response_iss_parameter_supported in RFC 8414 metadata.

Resource-not-found already used -32602 and tools/list ordering was already
deterministic; both are covered by the new test file.

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

* feat(mcp): Tasks extension (io.modelcontextprotocol/tasks)

Durable handles for long-running MCP tool calls, per the official Tasks
extension. A client that declares the extension in its per-request
capabilities gets a CreateTaskResult (resultType: "task") immediately;
the work completes after the response via after() and lands in the new
mcp_tasks table for tasks/get polling. Clients that did not declare the
extension are never handed a task (spec MUST).

- New mcp_tasks table (migration 20260729094000): company-scoped SELECT
  RLS, service-role-only writes (mirrors pending_operations), 1-hour
  expiry, status lifecycle CHECK. pg-real coverage included; triaged as
  excluded in the full-archive backup contract (transient state).
- tasks/get (creator-scoped), tasks/cancel (cooperative, working-only
  flip), tasks/update (ack no-op: no input_required flows yet).
- Tool opt-in via shouldRunAsTask predicate; first producer is
  gnubok_audit_package, the one genuinely long-running blocking call
  (multi-minute ZIP generation). estimate_only stays synchronous.
- Tool failures complete the task with the standard isError envelope,
  exactly what the synchronous call would have returned; the failed
  status stays reserved for infrastructure errors.
- server/discover and initialize now advertise the tasks extension.

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

* fix(mcp): creator-only task RLS, enforced expiry sweep, RoPA entry

Compliance-swarm follow-ups on the mcp_tasks migration (editing the
migration is safe: it has not shipped beyond the ephemeral PR preview):

- SELECT RLS tightened from company-wide to auth.uid() = user_id so the
  DB grant matches the creator-scoped tasks/get contract; task results
  carry raw tool output (Art. 5(1)(c)). pg test now proves a same-company
  colleague cannot read the row.
- The 1-hour retention is now enforced, not aspirational: createMcpTask
  opportunistically deletes expired rows on every creation
  (idx_mcp_tasks_expires), best-effort (Art. 5(1)(e)).
- RoPA entry mcp.async_task_handles added to .compliance/ropa.yaml
  (Art. 30).

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

* fix(mcp): literal terminal-update payload for the phantom-column guard

The conditional spreads in resolveMcpTask made the payload unresolvable
for the no-phantom-columns guard (362 > 360 ceiling). A literal payload
writing null for absent terminal fields is equivalent here: the terminal
transition sets the complete terminal state.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:15:06 +02:00
Mattsson 3bbf2a051b Fix/bank sync bas (#1284)
* fix(year-end): stop revaluing FX items that were not on the balance sheet

The year-end close ran currency revaluation as an unconditional step before
the irreversible close, and the revaluation queried LIVE open invoices with
no date scoping. An invoice issued after balansdagen, settled before it, or
never booked at all was therefore revalued into the year being closed,
writing down a 1510/2440 that stood at zero. Because the entry lands inside
the same run that closes the period, the only remedy left was a rattelse in
the following year.

The population is now measured as of balansdagen, reusing the reconstruction
the reskontra reports already use (fetchPaymentsAsOf / outstandingAsOf): the
invoice_date ceiling is unconditional (post-dated invoices make the bug
reachable for a current period too) and the widening to 'paid' applies only
to a historical date, where a since-settled invoice was still open then.

Rows that carry no balance-sheet exposure are skipped per row rather than per
company: an unbooked registration is not on 1510/2440. Deliberately NOT keyed
on accounting_method, since BFL 5 kap 2 § 3 st requires kontantmetoden
companies to book their outstanding fordringar/skulder at balansdagen, and
those converted rows are genuine exposure that ARL 4 kap. 13 § must value.

The readiness warning stays ungated on purpose: an unbooked FX row is exactly
what deserves a warning, because /book still posts it into the year about to
close and lockPeriod/closePeriod then removes that remedy for good.

The wizard preview now lists the per-invoice revaluation rows it will post
instead of three aggregate numbers, so the user approves line-level content
before the close.

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

* fix(bookkeeping): reach accounts outside BAS 2026 from a verifikat rattelse

A user could not move a verifikat line to konto 8022: the picker reported no
such account and offered no way forward. 8022 was dropped from BAS 2026 (it
is in BAS 2018), so it is a legitimate company-specific underkonto rather
than a catalog gap. Verified against the official bas.se kontoplan that our
BAS reference already matches BAS 2026, so 8022 is deliberately NOT added to
it: seeding a retired account would push it onto every company.

StrikeLinesDialog and CorrectionEntryDialog were the only account pickers in
the app that never passed onCreateAccount, so their combobox rendered a dead
empty state. Both now open AddAccountDialog prefilled, then refetch the chart
and select the new account on the initiating line, leaving the half-finished
rattelse intact.

AccountCombobox closed its dropdown on the fourth digit of any committed
number, which hid the empty state before it was ever painted and made the
create affordance unreachable for exactly the numbers that need it. It now
closes only when the number matches something, so focus still advances to the
belopp field for real accounts.

No change to posting rules: correct_entry_lines_inline validates chart
membership, not BAS membership, and account creation already required the
same write role.

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

* fix(vacation): adjust vacation accrual calculations for mid-year hires and update related logic

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:28:51 +02:00
Jakob Wennberg 0df05c83c6 fix(email): calm, correctly-signed consent expiry notification (#1276)
* fix(email): calm, correctly-signed consent expiry notification

The consent expiry email was signed with the recipient's own company
name instead of the app, used red alarm chrome (header pill + button),
and never said why the recipient got it. After the #1271 health probe
drained a backlog of 25 dead sessions in one 05:00 cron run, that
design read as phishing to a batch of users at once.

- Sign off as the app; the company the connection belongs to moves
  into a details row and the why-did-I-get-this footer
- Drop all red/orange chrome; neutral editorial layout, pill button
- Explain that PSD2 consent expiry is routine and that no data is lost
- Show the destination URL as plain text next to the button
- Calmer subjects (renewal framing instead of 'synkronisering stoppad')
- Reply-to support instead of dead-ending at noreply
- Add template tests (was untested)

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

* fix(enable-banking): pause consent expiry emails behind env flag

Founder call 2026-07-29: the in-app surfaces already flag a dead
connection, so the cron email adds noise. Status transitions keep
running; set BANK_CONSENT_EXPIRY_EMAILS=true to resume sending.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:20:17 +02:00
Jakob Wennberg 80a14ddfd2 feat(mcp): dimension parity for the write/read tool edges (#1274)
Closes the MCP dimension gaps found in the 2026-07-28 audit:

- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
  all three layers (tool schema + BulkBookInboxSchema + categorize-core
  BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
  web inbox bulk-book route and the pending-op executor inherit it via
  the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
  default_dimensions (names resolve to codes; {} clears on update).
  The command layer already persisted the field: only the MCP boundary
  blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
  the GIN index, covers custom dims the legacy project/cost_center
  filters cannot) + include_dimensions to return each line's bag.
  The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
  default_dimensions (agents could set invoice bags but never read
  them back).
- Discoverability: create_voucher, categorize_transaction,
  correct_entry, update_invoice descriptions now name dimensions;
  categorize_month and invoice_run loadouts include
  gnubok_list_dimensions. Trimmed new schema prose to stay under the
  tools/list payload budget.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:39:53 +02:00
Jakob Wennberg 222e581476 feat(categorize): dimension bags end-to-end + runtime template learning (#1273)
* feat(categorize): dimension bags end-to-end + runtime template learning

The categorize path could not tag: categorize-core accepted a dimensions
bag but no route or UI ever passed one, and runtime template learning
dropped the bag entirely (only SIE import produced dimension-carrying
patterns).

- CategorizeTransactionSchema gains dimensions; the dashboard route, v1
  single and v1 batch-categorize apply it to the mapping result's
  business lines (explicit bag wins over a learned counterparty bag).
- categorization_templates.default_dimensions (migration 20260728091000)
  records the bag of the latest tagged booking; latest-explicit-wins, an
  untagged booking never erases it. Applied on the legacy single-line
  template path and the mirrored-refund path; multi-line SIE patterns
  keep their authoritative per-entry bags.
- QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled
  gate, same as BulkBookDialog), prefilled from the counterparty
  suggestion's learned bag; hidden for multi-line patterns whose per-line
  bags would ignore an edit.

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

* Renumber migration above 20260728120000 (out-of-order vs prod after #1271)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:39:23 +02:00
Jakob Wennberg 951b33363b feat(recurring): carry dimension bags on recurring invoice schedules (#1272)
Schedules and their template items now store {sie_dim_no: code} bags
(default_dimensions / dimensions), and the cron generator copies them
onto every spawned invoice + item, so recurring invoices book with the
same projekt/kostnadsstalle tags a manual invoice would. Wired through
the web CRUD routes, the staged-operation executors, and the MCP
create/update/list schedule tools (resolve-don't-select, resolutions
echoed in the preview).

Migration 20260728090000 adds the two jsonb columns (same shape+CHECK
as invoices/invoice_items, PR7 producer parity).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:38:52 +02:00
Mattsson 65c6d4c178 Fix/07 27 (#1271)
* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions

A PSD2 reconnect silently moved the user's ledger mapping. Account identity
came from the provider's account uid, which does not survive a
re-authorization at every ASPSP, and a fresh connect to an already-connected
bank mints a new bank_connections row regardless. Both paths looked like "an
account we have never seen", so the allocator handed out the next free 19xx
slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent
renewal, roughly quarterly per connection.

Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing
cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2
promotes that row in place rather than inserting a second one, so it keeps its
id and its linked transactions and is re-pointed at the connection that just
authorized. The previous holder's connection status is deliberately ignored:
one IBAN is one physical account, and the old row often still reads 'active'
because the bank killed the session without telling us.

The allocator also stopped treating a 19xx number as free just because no
cash_accounts row holds it. A chart imported from SIE carries the company's
real bank accounts by name with no PSD2 row behind them, which is how a SEK
company account got proposed as an unrelated brokerage account. Overflow now
skips chart-occupied numbers, falling back only when nothing unnamed is left.

Dead connections kept rendering as "Aktiv": status only ever changed when a
transaction fetch failed, so a session killed bank-side stayed healthy-looking
with a stale last_synced_at while the user read old balances as current. Add
probeSessionHealth() and run it in the daily cron over every connection that
run did not prove alive, including the ones the loop skips silently
(capability gate, all accounts deselected) and the ones parked in
pending_selection that the cron never looked at. It acts only on a definite
dead answer; anything ambiguous leaves the row alone, since a wrong flip costs
a full BankID re-authorization. The all-accounts-deselected branch is
reclassified 'synced' to 'skipped' for the same reason: it never contacts the
bank, so it must not count as proof of life. The settings row warns when an
active connection has not synced in three days or has never synced.

Which company a connection belongs to was invisible. Everything was already
scoped to ctx.companyId, so there was no cross-tenant leak, but a bank
authorized while the wrong company was active looked identical to the right
one. Name the company on the connect surface and in the account picker, and
say where the connection went when the callback lands under a different active
company. Warn (bypassably) before authorizing a bank where the same user
already holds live connections in other companies: several ASPSPs allow one
active AIS session per login, so the new authorization can kill the others.

The history start date already defaulted to the fiscal-year start; the card
above it recommended a mid-year date and contradicted the selected option. It
now states the fact and offers the shortcut without presenting it as advice.

Not addressed: sharing one PSD2 session across companies. company_id is the
tenancy anchor on bank_connections and cash_accounts hangs off
(company_id, bank_connection_id), so that needs the session to become its own
entity. See DECISIONS.md.

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

* fix(supplier-invoices): show the posted line description in the voucher preview

The "Verifikation som bokförs" preview built its expense debit lines with
description set to the raw account number, so the BESKRIVNING column showed
"5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura
123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for
2440/2641/26xx, which is why the column read as a mix of friendly labels and
bare account numbers, neither of which was the posted text.

The preview now renders exactly the line_description the engine writes: the
shared invoice-level text on expense lines and 2440, "Ingående moms {rate}%
{desc}" on 2641, and the reverse-charge pair taken straight from
generateReverseChargeLines instead of being re-derived locally.
buildSupplierDescription moves into its own dependency-free module so the
client-side preview can call it without pulling the journal engine (and its
Supabase server client) into the browser bundle. The account name stays
reachable on the AccountNumber hover card.

Picked option A from the issue, keeping the fixed invoice-level description
rather than propagating each item's own text: the customer-invoice side
already writes invoice-level descriptions, so per-item text would create an
inconsistency between the two invoice sides rather than remove one, and it
would need an aggregation-collision policy in the journal engine. Rationale
recorded in DECISIONS.md.

Refs #1258

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

* fix(bookkeeping): restore the copy icon on verifikat rows

The row-language rewrite in #1123 reused the copy icon's slot for the new
expand toggle, removing the zero-click copy affordance from the bookkeeping
list without mentioning it. The leftover orphaned copy_voucher_tooltip key
in both message files is what identifies it as collateral rather than a
product decision.

Restore a copy icon in the row's right-edge action cell, reusing that key
for aria-label and title. stopPropagation keeps the click off the row's
expand toggle. The icon is hover-revealed on md+ and always visible below
it: #1123 collapsed the desktop table and the mobile card into one
responsive table, so hover-only would leave touch users with nothing.

Copy is no longer gated on posted. The copy_from handler and the GET
journal-entries route never looked at status, so copying a draft already
worked end-to-end and only the detail-page button hid it; the two list
surfaces were already ungated. Both list affordances now respect canWrite,
which previously dropped read-only users into a dialog they could not
submit.

The repo does not render components in tests, which is why #1123 removed
this silently. Pin the source shape instead, the same way the copy-invoice
query is pinned.

Closes #1266

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

* fix(transactions): revalidate stale invoice match pointers before offering a match

potential_invoice_id / potential_supplier_invoice_id are written once, at bank
import, and never revisited. When one of several identical recurring invoices
was settled by a different transaction, every other transaction kept pointing
at the now fully paid invoice. The match dialog then measured the bank amount
against a 0 kr remaining balance and reported a "Beloppen skiljer sig ...
fakturan blir delbetald" partial payment, and the worklist offered the same
dead suggestion as a one-click confirm row.

Worse, the manual escape hatch was hidden exactly when it was needed:
TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no
suggestion exists, so a stale pointer left the user with no way at all to
reach the correct invoice.

Fixed by revalidating at read time rather than by clearing sibling pointers on
settle. Invoices are settled through many paths (both match routes, mark-paid,
MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment
one is missed, while the candidate lookup covers every route into the list.
The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS
guards the match routes already enforce.

  - listSuggestedMatches and the transactions page candidate fetch filter on
    status + remaining_amount, so a settled candidate yields no suggestion and
    the manual picker reappears on its own.
  - InvoiceMatchDialog blocks a settled target with a distinct message and a
    disabled confirm. Not advisory: both routes reject it outright with
    MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could
    succeed.
  - The supplier detail card now shows remaining_amount like the customer
    branch, instead of total. On a partially paid invoice it used to print
    "1 250 kr" directly beside "Differens: 1 250 kr".
  - match-supplier-invoice clears potential_supplier_invoice_id on the
    transaction it just matched, mirroring the customer route.

No bookkeeping was ever at risk: both routes already refused a settled target
before creating a voucher. The damage was confined to a misleading dialog and
a dead end.

createQueuedMockSupabase gains passive call recording (calls / findCall /
findCalls) because the proxy swallowed filter and update arguments, which made
the new assertions inexpressible.

Refs #1259, #1260

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

* feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256)

* feat(webhooks): dispatch on emit instead of waiting for the next cron tick

The webhook dispatcher ran only on a per-minute cron, so the floor on
delivery latency was up to 60 seconds plus the request. An external consumer
that wanted to react as a transaction landed had only one alternative:
polling /api/events, which the 100 rpm per-key limit makes expensive and
which still cannot beat the tick interval.

Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is
unchanged and remains the retry and sweep path; this only moves the first
attempt forward. Wired into the event-bus fanout plus the two routes that
enqueue a delivery directly: the :test verb, whose entire purpose is telling
someone whether their receiver works, and the manual delivery retry.

Three properties are load-bearing and covered by tests. The kick is never
awaited, because eventBus.emit is awaited at ~99 call sites including
journal_entry.committed and each delivery can burn a 10 s receiver timeout.
It coalesces per function instance, so a bulk booking that emits once per row
does not schedule one claim round trip per row. It claims 5 rows rather than
the cron's 50, because it runs on the tail of a user-facing request.

Double delivery is not a risk: claim_due_webhook_deliveries already claims
FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so
a kick racing the cron sees disjoint rows.

Does not close #1201, which asks for a realtime stream for API consumers.
This is the cheap half.

Refs #1201

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

* docs(webhooks): stop claiming the kick makes double delivery impossible

Adversarial review of the previous commit caught an overstatement in its own
comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at
the same moment, but claim_due_webhook_deliveries autocommits before any POST
is issued, so from then on ownership is only status='in_flight' and a later
cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an
earlier cycle's serial loop.

Delivery is at-least-once, which is what the public docs already tell
receivers ("the same delivery id may arrive more than once ... idempotency is
on you"). The comments contradicted that.

No behaviour change. The kick does not create this window: the cron claims 50
rows serially against the same 20 s stuck threshold, which is wider than what
a batch of 5 can open.

Refs #1201

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253)

* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base

sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax
calculation, because generateIncomeStatement excludes every
source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so
planenlig avskrivning posted by the bokslut flow
(lib/bokslut/assets/depreciation-engine.ts) was dropped from the income
statement and never added back. The bolagsskatt base and the
periodiseringsfond 25 % cap were therefore computed on an overstated result:
tax too high by roughly 20.6 % of the depreciation.

Also exclude the period's final bokslutsverifikation from the fetch. It
carries source_type='year_end' as well and reverses every P&L account,
78xx/88xx/7533 included (verified against production closing entries), so
once the year is closed it would cancel the add-back this function exists to
produce. That hazard already applied to 88xx and 7533; the fix closes it for
all three rather than widening it.

Scope is deliberately the tax base only. Making the standalone
resultatrakning show bokslut entries is a separate, larger change: the same
exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed
profit for every company that ran the bokslut flow, and it means removing
the add-back at four call sites.

Refs #1051

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

* fix(bokslut): scope the closing-entry lookup to the company and fail loudly

Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new
fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair.

It filtered only on the period id while every sibling query in the same
function carries the tenant scope. Primary key or not, service-role paths
have no RLS to fall back on and the repo's rule is to filter company_id
explicitly, so it now does.

It also discarded the query error. That mattered more than it looks: a failed
read fell through to closingEntryId = null, which silently re-admits the
closing verifikat's 78xx/88xx reversals and understates the tax base, i.e.
exactly the failure this lookup was added to prevent. It now throws, and the
surrounding catch turns it into the existing 'Failed to read posted
dispositions' error. A wrong bolagsskatt is worse than a loud failure.

Two regression tests: the lookup carries both eq filters, and a lookup
failure propagates instead of degrading to a wrong number.

Refs #1051

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(storage): drop the client-side DELETE policy on the documents bucket (#1254)

* fix(storage): drop the client-side DELETE policy on the documents bucket

20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies".
That described the repo, not production. Production carries a
users_delete_own_documents policy that exists in no migration file:

  FOR DELETE TO authenticated
  USING (bucket_id = 'documents'
         AND (storage.foldername(name))[2] = auth.uid()::text)

Under it, the uploading user can delete the storage bytes of any document
they uploaded under the legacy documents/{userId}/... layout, using nothing
but their normal browser token. That includes documents linked to a posted
verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year
retention duty. deleteDocument()'s linked-check and the
block_document_deletion() trigger both guard the document_attachments ROW,
not the object: the row survives, still pointing at a file that is gone.

Reproduced against a local replay of the full migration stream: with the
policy present the uploader's own DELETE removes the object; with it dropped
the same statement matches zero rows. Company-scoped keys were never exposed
(their second path segment is the company id, not auth.uid()), so this only
ever reached the legacy layout, which is where most documents still live.

Safe because every in-app remove() on this bucket already runs on the service
role, covered by service_role_all_documents.

Deliberately narrow: users_read_own_documents and users_upload_own_documents
stay. The Phase B backfill from 20260726092000 has not run, so dropping the
legacy SELECT policy now would make existing documents unreadable. That is
Phase C.

The pg-real test asserts no DELETE and no UPDATE policy over the bucket under
ANY name: the hole arrived under a name this repo never used, so pinning a
name would not have caught it.

Refs #1208

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

* test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies

Review caught two blind spots in the ratchet, both fair. It matched only
polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as
effectively, and FOR ALL is the shape the one legitimate policy on this table
already uses, so a hostile one would look unremarkable in the catalogue. It
also read only polqual, so an UPDATE policy carrying its bucket restriction in
WITH CHECK was invisible.

Both assertions now run through one helper that covers d/w/*, concatenates
USING and WITH CHECK, and filters by grantee so service_role_all_documents
(how the application does its authorized deletes) is excluded while every
client-reachable role is not. A policy granted to PUBLIC has an empty
polroles, which is the most permissive case there is, so it is treated as
client-reachable rather than as "no roles".

Matching on the substring rather than the exact `bucket_id = 'documents'`
shape pg_get_expr emits today: a policy written as bucket_id::text or with the
comparison reversed would slip past a stricter match, and for a WORM ratchet a
false alarm is cheap while a silent hole is not.

Adds a probe case that creates a FOR ALL policy and asserts the helper sees
it, so the main assertion cannot pass vacuously. That case earned its keep
immediately: it caught that node-postgres hands back a raw string for a name[]
column, so the role filter needed rolname::text to work at all.

Verified against a local replay of the full migration stream: red with the
original prod FOR DELETE policy present, red with a FOR ALL probe, green
without either. Full pg-real suite 933 passed.

Refs #1208

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

* test(storage): catch a destructive policy that names no bucket at all

Adversarial review of the previous commit found the ratchet still failed
open, and reproduced it: a policy with no bucket_id predicate covers EVERY
bucket, documents included, so gating on the bucket name discarded exactly
the widest hole. The concrete shape is Supabase's own stock "Enable delete for
users based on user_id" template, USING (auth.uid() = owner), which is the
single most likely form of a future dashboard edit.

A destructive policy is now in scope unless it provably cannot reach this
bucket, i.e. only a bucket_id predicate naming some other bucket exempts it.

The behavioural assertions had the matching blind spot: fixtures were seeded
without an owner, so an owner-based policy matched NULL and the DELETE
reported 0 rows for the wrong reason. Objects now carry an owner the way
storage-api stamps them in production, so those tests fail loudly instead of
passing by accident.

Two probes pin both directions: a bucketless policy must be reported (and is
shown to really permit the delete), and a policy scoped to another bucket must
not be, so the ratchet cannot start crying wolf on receipts or sie-files and
get switched off.

Verified against a local replay of the full migration stream: red with the
stock bucketless template installed, green without it. Full pg-real suite 935
passed.

Refs #1208

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(kontoplan): make a deactivated account reachable again (#1262)

is_active=false read as "does not exist" on every read path but as "exists"
on the (company_id, account_number) unique constraint, so a deactivated
account vanished from the kontoplan with no way back and re-creating it
answered "Kontonummer X finns redan i din kontoplan."

The write side was already correct: POST /accounts/activate has a
toReactivate branch and PUT /accounts/[number] accepts is_active:true.
Both were simply unreachable, so this opens routes to them rather than
relaxing the read filters, which are load-bearing for
AccountsNotInChartError.

- Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv"
  chip and the existing per-row switch reactivates them in one click.
- Deactivating an account that has posted lines now warns first, using the
  usage count already loaded for the Verifikat column.
- POST /accounts distinguishes the two collisions and returns the new
  ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot
  istallet" rather than a dead-end 409. The stored account is left exactly
  as it was; values typed into the failed create form are not applied.
- bas-lookup consults the company's own chart before the static BAS
  reference, so a deactivated custom account reads as known and
  "Aktivera och bokfor" is no longer disabled for it. New in_chart /
  is_active fields let callers tell "will be added" from "will be revived".
- BAS-katalog stops showing "Aktiverat" for an account the company holds
  but has deactivated; it falls through to a relabelled Aktivera button,
  and the per-class counts follow.

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

* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255)

* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off

A foreign supplier charging no Swedish VAT is normally omvand
skattskyldighet. With the reverse-charge switch off,
createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor
the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the
momsdeklaration takes a shape Skatteverket rejects. For a fully deductible
purchase the net moms att betala is unchanged, which is exactly why this goes
unnoticed. The form already auto-ticks reverse charge for eu_business but not
for non_eu_business, so that path slips through silently.

Adds a pure helper plus a non-blocking banner cloned from the existing
rc_account_warning block. Deliberately silent for swedish_business, where 0 %
is a genuine exemption that belongs in no ruta at all, and phrased as a
question rather than an assertion: a non-EU goods purchase cleared at customs
is legitimately 0 % without reverse charge, and pushing that user into
ticking the switch would manufacture a new wrong verifikat.

Does not add the exempt/import/other picker the issue proposes:
supplier_invoices.vat_treatment is metadata that no booking or ruta mapping
reads, and the codebase cannot book import VAT at all, so an import option
would imply ruta 50/60 were handled when they are not.

Refs #1042

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

* fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint

Review flagged that the most common foreign document a Swedish small company
sees is an invoice carrying the supplier's OWN local VAT, booked at 0 %
Swedish VAT with reverse charge correctly off. The banner fires there, and
the previous copy only offered "momsfri av annat skal, till exempel en
varuimport" as the way out, which does not describe that invoice at all: it
is not VAT-free, it carries foreign VAT.

Names both legitimate cases explicitly and says 0 % is correct in them, so
the hint cannot read as an instruction to tick reverse charge on a purchase
where that would produce a wrong verifikat. Title also narrowed to "utan
svensk moms" for the same reason.

Refs #1042

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244)

A named persona earns its name once someone has been through onboarding and
chosen it: it is their assistant and they named it. Nobody in the sandbox chose
anything, so a first name reads as a character the product invented and implies
a relationship the visitor never opted into.

Both halves move together, which is the point. profile_summary is the agent's
own self-description inside the system prompt, so leaving it as "Du är Anna"
would have the header say one thing while the assistant introduces itself as
another in its first sentence. Nothing else in the stack checks that pairing,
so a test now does.

Scope: this changes the seed, so new sandbox companies get the new name. The
483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a
profile exists, and its caller only runs while verified_at is null). Backfilling
those is a production write on demo data and is being raised separately rather
than smuggled into a code change.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(reports): show the last posted voucher per series in report headers

Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and
resultatrapport, so a printed or exported report answers which vouchers are
actually in it rather than only which dates it spans (#1267).

Reads MAX(voucher_number) over posted entries, never
voucher_sequences.last_number. The sequence counter is an allocation
high-water mark that drifts from the books in both directions:
next_voucher_number burns a number when the follow-up insert fails,
delete_last_voucher decrements by one instead of resetting to the new MAX,
and pre-RPC SIE imports left it behind. Since the point of the line is
avstamning, an allocated number would send a reconciler chasing a gap that
does not exist, so the label says plainly that the number is the posted one.

Scoped to the report own date range, so a Q1 report printed in November says
something true about Q1. The balansrapport keeps the fiscal-year start as its
lower bound because it accumulates. Skipped on a dimension-filtered
resultatrapport: that report already discloses it is partial, and an
unfiltered voucher range beside a filtered result invites the wrong
conclusion.

Populated in both engines, so the JSON, PDF and XLSX routes all inherit it
without signature changes. Best-effort: a header nicety never breaks a
report. The pure formatter lives in its own module so the client view does
not pull the Supabase query path into the browser bundle. No new i18n keys;
both report views and the PDF template are hard-coded Swedish per the
"stays Swedish" report surfaces in .claude/rules/i18n.md.

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

* fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263)

customers.personal_number holds AES-256-GCM ciphertext (20260726110000).
Three defects compounded into one broken surface for private customers.

The list queried Supabase from the browser with select('*') and rendered the
raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads
GET /api/customers, which already masks every row, so the ciphertext never
leaves the server. Searching by personnummer works again: the client filter
had been matching against ciphertext and could never hit.

A row whose value cannot be decrypted renders as the placeholder
'********-????'. None of the three mask checks recognised it, each having its
own '-1234'-only copy, so such a customer could not be edited in ANY field:
name and address edits 400'd on a personnummer the user had no way to
correct. All three now share one pattern from the new crypto-free
lib/customers/mask-personal-number.ts, which the client form can import.
Typing a fresh personnummer overwrites the unreadable value, which is the
only repair possible: the rejected writes failed whole INSERTs, so there is
nothing to backfill.

The value was write-only by construction. GET
/api/customers/{id}/personal-number is the deliberate drill-in, mirroring the
employee convention, gated on the write role because .compliance/ropa.yaml
listed no_full_value_read_endpoint as a safeguard for this column; that entry
is rewritten rather than left stale, and reveals log actor and customer id
but never the value.

Also: arcim-migration wrote the identity number as plaintext, which aborts
any import containing a Privatperson with 23514 since the constraint flip;
and the customer embeds on /api/invoices shipped ciphertext to the browser on
every invoice read.

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

* feat: enhance ruta 05 handling for dynamic revenue accounts

- Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261.
- Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts.
- Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05.
- Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations.
- Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration.

* fix: enhance data handling and masking in customer and invoice APIs

* fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split

3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a
balance on it is filed in the right box already. What was missing is the
rate split: unlike 3001/3002/3003 the account number carries no sats, and
fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A
company posting to the gruppkonto therefore got a ruta 05 total that
breakdown.invoices.base25/12/6 did not add up to.

Surface those rates separately as staticRateByAccount: rate-only on
purpose, because the static map already sums the account and adding it to
the dynamic account list would double the filed figure. A test pins that
single-count property.

Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display
list behind report.rutor.ruta05: without it a 3000 balance appeared in the
filed projection but not in the report the agent reads back.

The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was
wrong and is corrected. That check derives its expected base from the
output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads
the per-rate bases, which are reporting metadata. So the incomplete split
never affected a filed return or a warning, only the breakdown.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
2026-07-28 19:50:16 +02:00
Jakob Wennberg 151ef51cc5 feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244)
A named persona earns its name once someone has been through onboarding and
chosen it: it is their assistant and they named it. Nobody in the sandbox chose
anything, so a first name reads as a character the product invented and implies
a relationship the visitor never opted into.

Both halves move together, which is the point. profile_summary is the agent's
own self-description inside the system prompt, so leaving it as "Du är Anna"
would have the header say one thing while the assistant introduces itself as
another in its first sentence. Nothing else in the stack checks that pairing,
so a test now does.

Scope: this changes the seed, so new sandbox companies get the new name. The
483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a
profile exists, and its caller only runs while verified_at is null). Backfilling
those is a production write on demo data and is being raised separately rather
than smuggled into a code change.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:55:16 +02:00
Jakob Wennberg 10a7d961f3 fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255)
* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off

A foreign supplier charging no Swedish VAT is normally omvand
skattskyldighet. With the reverse-charge switch off,
createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor
the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the
momsdeklaration takes a shape Skatteverket rejects. For a fully deductible
purchase the net moms att betala is unchanged, which is exactly why this goes
unnoticed. The form already auto-ticks reverse charge for eu_business but not
for non_eu_business, so that path slips through silently.

Adds a pure helper plus a non-blocking banner cloned from the existing
rc_account_warning block. Deliberately silent for swedish_business, where 0 %
is a genuine exemption that belongs in no ruta at all, and phrased as a
question rather than an assertion: a non-EU goods purchase cleared at customs
is legitimately 0 % without reverse charge, and pushing that user into
ticking the switch would manufacture a new wrong verifikat.

Does not add the exempt/import/other picker the issue proposes:
supplier_invoices.vat_treatment is metadata that no booking or ruta mapping
reads, and the codebase cannot book import VAT at all, so an import option
would imply ruta 50/60 were handled when they are not.

Refs #1042

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

* fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint

Review flagged that the most common foreign document a Swedish small company
sees is an invoice carrying the supplier's OWN local VAT, booked at 0 %
Swedish VAT with reverse charge correctly off. The banner fires there, and
the previous copy only offered "momsfri av annat skal, till exempel en
varuimport" as the way out, which does not describe that invoice at all: it
is not VAT-free, it carries foreign VAT.

Names both legitimate cases explicitly and says 0 % is correct in them, so
the hint cannot read as an instruction to tick reverse charge on a purchase
where that would produce a wrong verifikat. Title also narrowed to "utan
svensk moms" for the same reason.

Refs #1042

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:41:25 +02:00
Jakob Wennberg 579f48752e fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253)
* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base

sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax
calculation, because generateIncomeStatement excludes every
source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so
planenlig avskrivning posted by the bokslut flow
(lib/bokslut/assets/depreciation-engine.ts) was dropped from the income
statement and never added back. The bolagsskatt base and the
periodiseringsfond 25 % cap were therefore computed on an overstated result:
tax too high by roughly 20.6 % of the depreciation.

Also exclude the period's final bokslutsverifikation from the fetch. It
carries source_type='year_end' as well and reverses every P&L account,
78xx/88xx/7533 included (verified against production closing entries), so
once the year is closed it would cancel the add-back this function exists to
produce. That hazard already applied to 88xx and 7533; the fix closes it for
all three rather than widening it.

Scope is deliberately the tax base only. Making the standalone
resultatrakning show bokslut entries is a separate, larger change: the same
exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed
profit for every company that ran the bokslut flow, and it means removing
the add-back at four call sites.

Refs #1051

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

* fix(bokslut): scope the closing-entry lookup to the company and fail loudly

Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new
fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair.

It filtered only on the period id while every sibling query in the same
function carries the tenant scope. Primary key or not, service-role paths
have no RLS to fall back on and the repo's rule is to filter company_id
explicitly, so it now does.

It also discarded the query error. That mattered more than it looks: a failed
read fell through to closingEntryId = null, which silently re-admits the
closing verifikat's 78xx/88xx reversals and understates the tax base, i.e.
exactly the failure this lookup was added to prevent. It now throws, and the
surrounding catch turns it into the existing 'Failed to read posted
dispositions' error. A wrong bolagsskatt is worse than a loud failure.

Two regression tests: the lookup carries both eq filters, and a lookup
failure propagates instead of degrading to a wrong number.

Refs #1051

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:29:37 +02:00
Jakob Wennberg 49ff234954 feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256)
* feat(webhooks): dispatch on emit instead of waiting for the next cron tick

The webhook dispatcher ran only on a per-minute cron, so the floor on
delivery latency was up to 60 seconds plus the request. An external consumer
that wanted to react as a transaction landed had only one alternative:
polling /api/events, which the 100 rpm per-key limit makes expensive and
which still cannot beat the tick interval.

Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is
unchanged and remains the retry and sweep path; this only moves the first
attempt forward. Wired into the event-bus fanout plus the two routes that
enqueue a delivery directly: the :test verb, whose entire purpose is telling
someone whether their receiver works, and the manual delivery retry.

Three properties are load-bearing and covered by tests. The kick is never
awaited, because eventBus.emit is awaited at ~99 call sites including
journal_entry.committed and each delivery can burn a 10 s receiver timeout.
It coalesces per function instance, so a bulk booking that emits once per row
does not schedule one claim round trip per row. It claims 5 rows rather than
the cron's 50, because it runs on the tail of a user-facing request.

Double delivery is not a risk: claim_due_webhook_deliveries already claims
FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so
a kick racing the cron sees disjoint rows.

Does not close #1201, which asks for a realtime stream for API consumers.
This is the cheap half.

Refs #1201

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

* docs(webhooks): stop claiming the kick makes double delivery impossible

Adversarial review of the previous commit caught an overstatement in its own
comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at
the same moment, but claim_due_webhook_deliveries autocommits before any POST
is issued, so from then on ownership is only status='in_flight' and a later
cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an
earlier cycle's serial loop.

Delivery is at-least-once, which is what the public docs already tell
receivers ("the same delivery id may arrive more than once ... idempotency is
on you"). The comments contradicted that.

No behaviour change. The kick does not create this window: the cron claims 50
rows serially against the same 20 s stuck threshold, which is wider than what
a batch of 5 can open.

Refs #1201

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:08:24 +02:00
Jakob Wennberg 7c44cef66d fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted (#1249)
* fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted

invoice_date becomes the registration verifikat's entry_date and
supplier_invoice_number goes into its description, but both stayed freely
writable through the shared UpdateSupplierInvoiceSchema. Editing either on a
booked invoice moved the invoice row while the posted entry kept its original
values: the two disagreed silently, nothing landed in
journal_entry_rattelse_log, and the change bypassed both sanctioned rättelse
paths (BFL 5 kap 5-7 §).

Adds findLockedVerifikatFields() next to the other supplier-invoice lifecycle
predicates and calls it from both writers (dashboard PUT and v1 PATCH, which
also covers the API-key/MCP path). Only a differing value is refused, so a
full-form resend of the stored value still succeeds, and due_date,
payment_reference and notes stay editable for the aged-invoice flow (#1206).

Fixes #1230

* fix(supplier-invoices): make the verifikat-field lock atomic with the write

Review follow-up on #1230: the lock check read the row a moment before the
update ran, so a registration entry posted in between let exactly the drift
the guard exists to prevent slip through.

When an update moves a verifikat-critical field on a row that read as
unbooked, the write is now pinned with `registration_journal_entry_id is
null`. A concurrent posting therefore matches zero rows: the dashboard route
returns its existing SI_EDIT_CONFLICT ("reload and try again", and the retry
hits the lock with the right message), and the v1 route re-reads to answer
with SI_EDIT_VERIFIKAT_LOCKED plus reason=race rather than a guess.

The pin is conditional on the update actually moving one of those fields, so
metadata-only edits and full-form resends of unchanged values on a booked
invoice keep working (#1206).
2026-07-27 19:45:26 +02:00
Jakob Wennberg de461c2cf8 feat(support): report both channel outcomes on the feedback breadcrumb (#1252)
* feat(support): report both channel outcomes on the feedback breadcrumb

support_feedback_submitted recorded only whether the email delivered, so
"did the PostHog ticket actually open?" was unanswerable from PostHog.
The first time Support shipped, the only way to check was to reproduce
the submission with devtools open.

Both channels fail silently from the user's side, which is why this is
worth instrumenting: email is the delivery guarantee, so the UI shows
success even when the ticket failed, and a ticket that never opened
leaves nothing in PostHog Support to look at either.

Adds `email`, `ticket` and a derived `lost` to the event. `delivered` is
kept as-is so any existing insight filtering on it keeps working.

ticket: 'unavailable' is deliberately distinct from 'failed'.
Unavailable is the expected steady state (Support disabled, analytics
disabled, self-hosted); failed means conversations were live and the
call still did not land. Collapsing them would make the useful signal
unalertable. `lost` is true only when the message reached neither
channel, which is the one property worth an alert.

Still carries no message body: a test pins that free text never appears
in event properties.

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

* fix(support): run the ticket call concurrently and cap it

Addresses CodeRabbit's three findings on #1252.

The real one: submitFeedback awaited submitViaTicket AFTER the email,
so a hung sendMessage would hold the confirmation dialog open for as
long as it hung. The code comment claimed a slow ticket "must never
delay" the user while the code did exactly that.

Both channels now start together, so the user waits max(email, ticket)
rather than the sum, and the ticket is additionally capped at 4s. On
expiry it resolves to a new 'timeout' outcome rather than being rounded
to 'failed', keeping "conversations were live but slow" distinguishable
from "conversations errored". Email still decides ok either way.

Also: renamed the lost-state test, which claimed both channels failed
while configuring ticket: 'unavailable', and added the genuinely-failed
case alongside it plus coverage for the hanging-call path. Reformatted
the decision-log entry to the required [date] <decision>: <why> shape.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:30:34 +02:00
Jakob Wennberg 69c537fd1f fix(documents): anchor floating supplier-invoice underlag instead of nagging (#1248)
A verifikat booked from a supplier invoice showed the invoice PDF when opened
while the list kept warning "Underlag saknas" on the same row. Both surfaces
behaved as written: every missing-underlag surface only accepts a referenced
supplier-invoice document when it is ANCHORED to a journal entry (only anchored
docs sit behind block_document_deletion), while the verifikat view's reference
resolver displayed the document regardless.

The document was floating because delete_last_voucher clears journal_entry_id
on everything attached to the voucher it tears down (the FK is ON DELETE
RESTRICT, so it must). Deleting a rättelse the invoice PDF had been relinked
onto therefore orphaned it while the payment verifikat stayed posted, and
nothing ever anchored it again: the warning was unresolvable by design.

Same class one surface over: v1 mark-paid never linked the document at all,
dashboard mark-paid only did so for the cash entry, and both
match-supplier-invoice routes propagated the transaction's document but not the
invoice's own. Four of the five affected prod rows come from those paths, not
from a deleted voucher.

- lib/core/documents/supplier-invoice-underlag.ts: anchor a floating document
  to the invoice's own posted verifikat (registration, then payment, then
  partial payments; open unlocked periods only). Never moves an anchored doc,
  never throws.
- Called after delete_last_voucher and from all four payment paths.
- getJournalEntryUnderlagReferences withholds an unanchored document so the
  verifikat view and the warning can no longer contradict each other.
- Migration 20260727180000 backfills the rows already in this state.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:04:15 +02:00
Jakob Wennberg 771dfd45ba docs(compliance): record the deletion trigger for client-side storage (#1246)
Answers the ISO 27001 A.8.10 finding from the compliance swarm on #1242,
which read the storage inventory as saying the two localStorage keys
persist forever with no deletion mechanism. Half of that was our
omission: the inventory never said what logout does.

ph_conv_<token> IS deleted on logout. posthog.reset() resets the
conversations manager, which removes that single known key, and
resetAnalyticsIdentity() already runs in both logout handlers before
signOut(). That is what stops a shared device carrying one user's
support-ticket session into the next user's. Recorded in
lib/analytics/reset.ts too, because it now makes that call load-bearing
for a cross-user concern rather than mere tidiness. Verified by reading
the SDK, not by executing a logout, and the docs say so.

seenSurvey_<id> genuinely has no deletion trigger and cannot have one:
no PostHog bundle enumerates localStorage (zero occurrences of
localStorage.key( or Object.keys(localStorage) across module.js,
surveys.js and conversations.js), so nothing can discover the keys to
remove them. Stated as the accepted retention position rather than left
silent: the value is "true" under an opaque survey id with no personal
data, and clearing it on logout would re-prompt every survey to the next
person on the device and produce false survey responses.

Adds the review date the A.5.9 remediation asked for, and reframes the
review trigger as a pre-launch step, since Support was caught post-hoc
and left the privacy page inaccurate in the interval.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:07:22 +02:00
Jakob Wennberg 46c0b72ab0 feat(auth): surface duplicate-account traps around BankID login (#1234)
* feat(auth): surface duplicate-account traps around BankID login

Three escape hatches for the stale-duplicate-account trap (#1231, the
Chillen support case): a user whose BankID resolves to an abandoned
account got an empty app with no hint that their real bookkeeping
lives in another account.

- check-org-number: new exists_elsewhere signal (service role, reduced
  to one boolean) + a warn chip in the onboarding journey when the org
  number already exists in an account the user is not a member of.
- Hem: one AttnLine under the greeting when the whole account has zero
  journal entries but a same-orgnr company elsewhere has real
  bookkeeping, with a sign-out action. Common case costs one indexed
  existence probe.
- scripts/support/unlink-bankid.ts: dry-run-by-default support action
  that unlinks a BankID identity (delete + app_metadata clear +
  append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used
  to resolve the original ticket.

Closes #1231

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

* fix(auth): harden unlink script and paginate hint queries per review

- other-account-hint: fetchAllRows() on both company listings (PostgREST
  1000-row cap; byrå users can hold many memberships); the journal probes
  stay limit(1) existence checks.
- unlink-bankid: audit_log row is written BEFORE the delete so a partial
  failure can never delete without a trace; context queries fail closed
  instead of rendering an unknown account as empty; stdout no longer
  prints the personnummer hash or ciphertext (the unsalted hash is
  brute-forceable over the personnummer space); record_id now carries the
  identity row id and the snapshot includes id + linked_at.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:00:15 +02:00
Jakob Wennberg ff205951b1 fix(auth): store BankID personnummer ciphertext as raw bytea, not JSON-serialized Buffer (#1233)
Both writers of bankid_identities.personal_number_enc passed a raw Buffer
to supabase-js, which PostgREST serializes as JSON: every row stored the
literal text {"type":"Buffer","data":[...]} instead of iv|tag|ciphertext
bytes, so decryptPersonalNumber could never have read them (issue #1232).

- encryptPersonalNumberForStorage(): hex-encode for PostgREST bytea input
- decryptStoredPersonalNumber(): tolerant decode (raw bytea read-back,
  legacy JSON-Buffer text, Buffer, serialized object)
- migration 20260727170000 rewrites existing rows to raw bytes; prefix
  guard keeps it idempotent and skips already-raw rows. Conversion SQL
  verified read-only against prod: converted bytes decrypt with the live
  key (GCM tag valid, 12-digit result).

Closes #1232

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:58:48 +02:00
Mattsson fbd4b992f5 Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly

Replace the hardcoded light gradient background with bg-background and
add dark:prose-invert to the prose blocks so body text is readable on
dark cards.

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

* feat(cloud-backup): sync archives to Dropbox alongside Google Drive

Introduce a CloudStorageProvider interface so performSync builds the
archive set once and talks to storage only through it. Google Drive
keeps its existing behaviour; Dropbox is a second implementation, so
the compliance-relevant half (fingerprints, per-year layout, size
fallback, progressive persistence) cannot drift between targets.

Dropbox uses App folder access, matching the drive.file scope's "only
what the app created" guarantee. Uploads are single-shot under 8 MB and
chunked upload sessions above, every write verified against Dropbox's
content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so
Swedish file names survive the Dropbox-API-Arg header.

Each provider owns its extension_data keys, schedule, failure counter
and alert throttle, so a dead Dropbox token cannot pause a healthy
Drive backup. The google_drive_* keys and the /oauth/callback path are
untouched: both are wire format for already-connected companies.

isConfigured() gates /connect only. A deployment that loses its OAuth
credentials must not trap users with a connection they cannot remove
or a schedule they cannot switch off.

Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row
renders disabled without them. No migration: state is extension_data
JSON throughout.

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

* fix: remove merge-conflict markers committed in DECISIONS.md

The merge that brought main into this branch staged DECISIONS.md while
it still carried conflict markers, so cdc3a513 shipped an unresolved
hunk (compliance swarm ISO 27001 A.8.32).

DECISIONS.md is an append-only log, so both sides are kept: main's
systemdokumentation entry followed by this branch's Dropbox entries.
No decision was dropped.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:49:24 +02:00
Jakob Wennberg 52ec3ce497 feat(support): open PostHog tickets from the existing support dialog (#1239)
Enables PostHog Support through the Direct API (posthog.conversations),
restoring the second channel Recapt used to provide, but as a real ticket
linked to the person and their session replay instead of a black hole.

The in-app WIDGET stays off on purpose. It is a third-party floating chat
bubble, which is exactly what Recapt was: it would sit next to the
Assistenten FAB (which already has a hide_assistant_fab preference
because users wanted it gone), cannot follow the locked design system,
and its copy is not ours to keep Swedish. The conversations API gives the
same tickets from components/ui/support-link.tsx, which is already
on-design, Swedish and reachable from 8 surfaces.

A ticket is explicitly NOT treated as delivery. submitFeedback returns ok
only when the Resend email actually went out, even if the ticket opened.
Recapt's precise failure mode was reporting success on its own channel
while /api/support/contact was dead, and nobody is watching PostHog at
02:00. Tests pin that: ticket-only is ok:false.

Identity verification uses posthog.setIdentity(distinctId, hash) at
runtime rather than the identity_distinct_id/identity_hash init options
PostHog's settings page documents. init runs from
instrumentation-client.ts app-wide, before the user is known and
including logged-out pages, and PostHog fixes init values for the
session. setIdentity is a real method on the SDK (verified typed in
posthog-js 1.407.3), so the hash applies from AnalyticsIdentify once the
dashboard layout knows who the user is. Without the key it is skipped and
tickets fall back to browser-scoped with email recovery, which is the
normal state off hosted.

POSTHOG_SECRET_API_KEY is server-only, no NEXT_PUBLIC_ prefix: it signs
identity hashes AND authenticates external API requests, so unlike the
phc_ project token it is a real credential. Only the derived per-user
HMAC crosses to the browser.

Compliance: support free text is declared as its own data category
(user.content.support) in .compliance/ropa.yaml and named on the privacy
page. Analytics events still carry no message body (the breadcrumb sends
only the subject); a ticket carries what the user wrote, because that is
the point. Keeping the purposes separate is what stops the privacy page
drifting the way the Recapt row did.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:23:32 +02:00
Jakob Wennberg 248d98bd7e feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)
* feat(analytics): remove Recapt, PostHog is now the only analytics

Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.

Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.

The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.

Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.

Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.

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

* fix(analytics): purge Recapt storage left on users' devices

Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.

Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.

Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.

Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:08:32 +02:00
Jakob Wennberg c62d00bcb3 feat(invoices): preview invoices and underlag in the browser instead of downloading (#1228)
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it
from the Downloads folder (user request, christian@odinaero.se 2026-07-25).

- GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for
  in-browser review; anything else keeps the download behaviour every existing
  caller relies on. The filename still travels in the header, so the browser
  viewer's own save action produces the same name as the download button, and
  nosniff pins the content type.
- The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner
  PDF". It resolves the document through the same resolveInvoicePdfSource path
  as the download, so preview cannot become the shortcut that presents a
  re-render as the invoice the customer received: the archived delivery wins,
  a re-render is shown with its caveat, and an unreadable delivery history
  still asks instead of guessing. The archive dialog now remembers whether the
  user asked to view or to save, and its fallback does that.
- DocumentViewButton (supplier-invoice underlag, staged agent previews) points
  at the existing /api/documents/:id/inline proxy, so bilagor render in the
  browser. Navigation now happens straight from the click, so the signed-URL
  fetch and its popup-blocker workaround are gone.
- The three re-render caveat strings and the two archive-dialog descriptions
  lose their "you downloaded" wording so they stay true for both actions;
  five new keys in sv + en.

Tests: route cases for the default, inline and unknown disposition values;
invoiceRerenderUrl cases for both modes and id encoding. npm test 11364
passed, lint 0 errors. Button row screenshotted against the design system
(pill outline, Eye icon) via a temporary sandbox route.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:59:33 +02:00
Jakob Wennberg df29817826 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:49:22 +02:00
Jakob Wennberg f5697cfc2f feat(articles): filter the article register by currency (#1229)
Non-SEK article prices became first-class in #1166, so a mixed register needs a
way to look at one currency at a time (user request, christian@odinaero.se
2026-07-25).

- ContextPicker chip far right in the toolbar (convention 8), options = the
  currencies actually present in the register, default "Alla valutor". A
  single-currency register does not render it: a control with one meaningful
  position is noise.
- The scope lives in the URL alongside sort/dir, so a filtered register survives
  opening an article and coming back, and can be linked to. A code that is not
  in the register (last EUR article deleted, hand-edited link) falls back to
  "Alla" instead of hiding every row.
- Search and scope combine, and the no-matches copy names the scope when one is
  active: otherwise "no articles match X" reads as a claim about the whole
  register when it is only true inside the current currency.
- Predicates live in lib/articles/currency-scope.ts with unit tests; blank and
  legacy-null currencies fold into SEK the way the price column already
  displays them.

New strings in both messages/sv.json and messages/en.json. npm test 11369
passed, lint 0 errors, no new tsc errors. Chip screenshotted against the design
system via a temporary sandbox route.

Closes #1189

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:39:13 +02:00
Jakob Wennberg d4f82cafc4 feat(analytics): add PostHog (EU) behind a same-origin proxy (#1237)
Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.

Wiring choices that are not the tutorial defaults:

- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
  PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
  already cover it, tracking blockers have no third-party host to match,
  and the Recapt allowlist entries in next.config.ts get replaced by
  nothing at all when they go. Needs skipTrailingSlashRedirect, since
  PostHog sends trailing-slash API requests; verified that trailing-slash
  URLs on normal routes still resolve 200 rather than 404.

- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
  next.config rewrites, so without this updateSession() treats an
  ingestion POST as an unknown protected path and 307s it to /login.
  Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
  from PostHog. This fails silently otherwise, because asset loads keep
  working through the rewrite while no events arrive.

- persistence: 'memory' so nothing is written to the device and no
  cookie-consent banner is required. Everything post-login is unaffected:
  AnalyticsIdentify re-identifies on each dashboard load.

- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
  text by default, and this app renders org numbers (which for an
  enskild firma ARE the owner's personnummer), customer names and
  balances as ordinary text. Replays show where a user gets stuck, never
  what their books say. buildGroupProperties() also refuses to send
  org_number at all, with a test pinning it.

- Error tracking registers through the existing lib/observability sink
  rather than bypassing it, so every error-level createLogger() line is
  captured already redacted. instrumentation.ts onRequestError covers
  what escapes uncaught.

Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.

vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:30:49 +02:00
Jakob Wennberg 7dde8cac82 fix(security): resolve the CodeQL backlog, three fixes and three documented false positives (#1225)
Triage of all 9 CodeQL alerts surfaced on main by #1223. None were introduced by that PR.

Fixed: the compliance-review artifact now unpacks to runner.temp instead of over the trusted checkout (actions/artifact-poisoning, critical); MCP LIKE patterns escape backslash first, which was a real correctness bug returning wrong rows for any search containing a backslash (js/incomplete-sanitization, 2 sites); and the mcp-oauth consent form action is HTML-escaped (js/reflected-xss, not exploitable because WHATWG URL already percent-encodes " < >, but & is not in that encode set).

Dismissed as false positives with reasoning recorded at each site and in DECISIONS.md: sie-export escapeQuotes, where doubling backslashes would violate SIE 4B, corrupt files in conformant readers and skew #KSUMMA under BFL 7-year retention; hashApiKey, where SHA-256 is correct for a 256-bit CSPRNG token and changing it would invalidate every live gnubok_sk_ key; and the DuplicateBookingDialog href, which is a DB UUID behind a literal path prefix.

Regression tests cover both behavioural fixes, including the escape ordering.
2026-07-27 14:02:25 +02:00
Jakob Wennberg bcbe9b0903 feat(assistant): say what the conversation is anchored to (#1222)
* feat(assistant): say what the conversation is anchored to

agent_conversations.context_ref has been written since the first intents
shipped and read by nothing. The panel ignored it, so a thread resumed three
days later showed the messages with no indication of which invoice or which
bokslut it concerned, even though the row knew. /chat did worse: it printed the
ref raw, so the subtitle under someone's own conversation read
"invoice:5f3a-9c21-...", a database identifier shown to an accountant.

Both surfaces now render the same chip, which names the thing and links to it.
This matters more since the panel docks: sitting beside the page, "what is this
about" is a question the surface should answer rather than the user's memory.

The mapping is a data map in route-mapping.ts, not a switch in a component
(plan seam 8.5), so a flow run's ref renders in both surfaces with no change to
either. A ref it cannot read renders nothing rather than a broken chip.

Two refs deliberately have no link. There is no /transactions/[id] route, so a
transaction chip points at the list. The document inbox is an extension mounted
under /e/[sector], and core must not hardcode a path that exists only when the
extension is enabled, so that one is named without being linked.

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

* fix(assistant): review triage: make the colon test observable, drop an overclaim

The colon-splitting test asserted on a kpi ref, and kpi discards its id, so it
passed even with a parser that dropped everything after the second colon. Moved
to invoice:abc:2026, where the id reaches the href. Verified by switching
indexOf to lastIndexOf and confirming the test fails.

ContextChip's comment said a flow run's ref renders with no change to either
surface. It does not: an unknown kind maps to null and renders nothing until
the map gains an entry. The seam is that adding one is a single entry in one
file, which is what the comment now says.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:16 +02:00
Jakob Wennberg 16e1a84b4b fix(assistant): defuse memory prompt injection and bound replayed history (#1219)
* fix(assistant): defuse memory prompt injection and bound replayed history

The last two blocking items from dev_docs/assistant_redesign_readiness.md that
were never shipped.

Agent memory rendered into the system prompt verbatim. gnubok_remember_fact
commits immediately with no staging, and the model can be induced to call it by
untrusted text it read from a document or inbox item; the content then renders
for every member of the company, on every future turn, outside the
<tool_output> framing that exists for exactly this. A payload carrying newlines
and markdown could open what reads as a new prompt section. Memory lines are now
flattened before rendering (whitespace collapsed, structure-opening characters
defused at the start of a line) and the block carries the same
these-are-not-instructions framing tool output already had. The words survive:
this is about structure, not censorship.

Conversation history loaded unbounded, so every persisted tool result replayed
on every turn. Cost grew linearly with thread age and a long-lived pinned
conversation would eventually exceed the context window, at which point every
turn fails and, because the store is append-only, the thread is unusable for
good. The load is now newest-first with a cap and flipped back. Slicing a tail
can orphan a tool_result whose tool_use fell off the top: repairDanglingToolUse
already normalizes both directions, which is what makes the cap safe.

Verified: 11321 tests pass (7 new pinning the flattening, including that an
injected heading is defused while its words survive), lint and tsc clean,
guards pass.

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

* fix(agent): review triage: stop the memory flattener flipping a minus sign

The leading-marker strip removed any leading dash, so a stored fact of
"-50 kr i avvikelse" became "50 kr i avvikelse": a different number, in the one
part of the prompt that exists to carry facts about money, with nothing
downstream able to notice. A Markdown bullet is a dash, star or plus followed by
whitespace, so require that; inline emphasis stays as literal characters since
it cannot open a block anyway.

Also tie-break the 200-message history cap on id so the cutoff row is stable
across replays when created_at ties. Insertion order is deliberately not what
this restores: the ordering that matters, tool_use before its tool_result, is
already reconstructed by repairDanglingToolUse, which is what makes slicing a
tail safe in the first place.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:18:54 +02:00
Jakob Wennberg 2d543ac999 feat(agent): move every model call to Sonnet 5 (#1218)
* feat(agent): move every model call to Sonnet 5

Sonnet 5 is verified enabled on our Bedrock account already: a live probe of
eu.anthropic.claude-sonnet-5 in eu-north-1 answered normally, so no model-access
request was needed. The bare anthropic.claude-sonnet-5 is rejected (on-demand
throughput needs the cross-region inference profile), so the eu. prefix we
already use stays.

This is not a model-string swap. Sonnet 5 REJECTS the fixed thinking budget
outright: thinking {type:'enabled', budget_tokens} returns 400 "not supported
for this model. Use thinking.type.adaptive and output_config.effort". Every
chat intent set a budget, so the assistant would have failed on the first turn
after a bare ID change. Reasoning depth is now an effort level (STANDARD high,
DEEP xhigh), and max_tokens is explicit per tier rather than derived from a
budget that no longer exists.

display:'summarized' is load-bearing, not cosmetic. The default is 'omitted',
which still emits thinking blocks but with empty text. Measured on our own
account at xhigh effort: summarized returned ~1k characters of reasoning, the
default returned none. Without it the collapsible "Tänker ..." block in the
chat would have gone silently empty, which no mocked test would have caught.

Ceilings are raised (16k standard, 24k deep) because Sonnet 5's tokenizer
produces roughly 30% more tokens for the same text and max_tokens now caps
thinking and the visible reply together.

Also resolves the Opus 4.7 landmine recorded in the readiness doc: the composer
comment told ops to flip BEDROCK_OPUS_MODEL_ID to Opus 4.7, which would have
400d every thinking intent against the legacy budget shape. Both model
constants now point at Sonnet 5 and the stale instruction is gone.

Checked but deliberately unchanged: forced tool_choice in atom-selection. The
Sonnet 5 docs require thinking:{type:'disabled'} alongside a forced tool_choice
on Bedrock; probed against our account, the forced call succeeds without it, so
no change was made rather than adding a guard we cannot show is needed.

Other call sites moved too: invoice-inbox extraction, document extraction, the
compliance config, and the CI/CD workflows (pr-agent MODEL and MODEL_WEAK,
swedish-compliance-review, compliance-swarm).

Verified: 11315 tests pass, lint and tsc clean on every touched file, guards
pass.

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

* fix(agent): review triage: keep the no-thinking output ceiling, finish the model sweep

max_tokens now caps thinking and the visible reply together, so collapsing the
two tiers into one made every non-thinking intent inherit a 16000 ceiling where
it used to have 4096. Give it its own MAX_TOKENS_NO_THINKING instead, set to the
old 4096 scaled ~30% for Sonnet 5's tokenizer so the effective reply length is
unchanged rather than quietly cut.

scripts/swedish-compliance-review.mjs still fell back to Sonnet 4.6 when
REVIEW_MODEL was unset, so a manual run silently used the old model. The initial
sweep only covered .ts and .yml.

pr-agent's FALLBACK_MODELS listed the primary model as its own fallback, which is
not a fallback; dropped it and rewrote the surrounding comments, which still
described Opus 4.8 and a 200k window.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:40 +02:00
Mattsson f3eacb436d Fix/articles (#1216)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

* fix(review): remediate the 2026-07-27 compliance and security review findings

- ROT/RUT deduction base is arbetskostnaden INKLUSIVE moms (HUSFL 2009:194
  6-9 par.): computeDeduction takes the line vat_rate, all five call sites
  pass it, and tests pin Skatteverkets worked example (18 000 kr excl =
  22 500 incl, ROT 6 750).
- Momsdeklaration: new SALES_OUTPUT_VAT_SHORTFALL warning catches output VAT
  short of the reported sales base (one-directional, never filing-blocking).
- SIE import: #RAR records validated for every year index (dates, ordering,
  18-month BFL cap as warn-and-keep).
- build-invoice-write: SEK invoices populate the *_sek twin columns (rate 1)
  so both creation paths produce the same row shape.
- CI: daily Trivy SCA scan of the npm lockfile (replaces removed Dependabot);
  compliance review fails loudly on empty review.md.
- arcim migration FX logging routed through the redacting structured logger.
- docs/security/: authorization policy for the SIE bulk-delete RPC pair and
  the observability redaction contract.
- Rewrote the swedish-payroll ob-overtime reference (was a byte-identical
  copy of sick-pay.md); skills:generate emitted the atom-body seed migration.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:54:42 +02:00
Mattsson f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Jakob Wennberg f0f3050f54 fix(assistant): stop the chat loading in stages (#1210)
* fix(assistant): stop the chat loading in stages

PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7).
No redesign; this is the "it loads in different stages" complaint, traced to
four separate staging points and one dead link.

Resumed conversations rendered a column of EMPTY bordered cards until the
markdown chunk arrived, then filled in all at once and reflowed the thread. The
chunk was deferred with a null fallback, which is invisible while a reply
streams (nobody reads that fast) but very visible on hydrate, where every
assistant bubble is already text. The chunk is now prefetched as soon as any
chat surface mounts, and until it resolves the raw text renders instead of
nothing, so a bubble is never blank.

Clicking the assistant launcher showed NOTHING until the sheet chunk loaded:
the dynamic import had no loading state at all. It now renders a skeleton in
the same geometry, and the chunk is warmed on idle so the click usually hits an
already-loaded module.

/chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed
as a 48px rail, so every load snapped one to the other. The skeleton now matches
what actually mounts, per breakpoint.

The first turn read agent_profiles twice: once in the route to build the intent's
prompt template, once again in run-turn for the system prompt. The route now
hands its result over. Ranked memory is deliberately NOT shared: the two queries
differ (the route's selects fewer columns and orders without is_pinned, and
run-turn needs ids to stamp last_accessed_at), so reusing it would silently
change both the prompt and memory touch.

Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new
reads ?prompt=, so the typed question was silently dropped and the user landed
on an empty state.

Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file,
guards pass.

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

* fix(assistant): make the prefetches fail-safe and bounded

Review follow-ups on the staged-loading batch.

A rejected markdown import left the cached promise permanently rejected, so
every bubble for the rest of the session stayed on the plain-text fallback and
the rejection went unhandled. The cache is now cleared on failure so a later
surface retries, and the rejection is swallowed.

requestIdleCallback can defer indefinitely on a page that never goes idle; the
2s fallback only applied where the API is missing. The idle request now carries
a 2s timeout, and the warm import cannot produce an unhandled rejection either.

Adds the first-turn test for the profile-summary handover: it asserts the value
read for the prompt template is what reaches the turn, so a regression that
re-introduces the second read (or drops the template) fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:10:47 +02:00
Jakob Wennberg ee8ddb3849 fix(assistant): stop cross-user conversation access, bricked threads and lost sessions (#1209)
* fix(assistant): stop cross-user conversation access, bricked threads and lost sessions

Hotfix batch (PR1 of the assistant UI makeover, dev_docs/assistant_redesign_plan.md
section 7). No visual change; each of these is wrong today regardless of which
design lands, and three are unrecoverable per incident.

/api/agent/invoke never checked who owns a resumed conversation_id. RLS on
agent_conversations/agent_messages is company-scoped, not user-scoped
(20260517204000), so a member could post a colleague's conversation id, have
their history loaded into the prompt and read it back, while their own turns
were appended to that thread. The conversations list route filters on user_id
for exactly this reason. Also pins company and intent: resuming a thread from
another company would mix ledgers, and resuming under a different intent would
swap the tool whitelist under history the model has already seen.

A turn persists the assistant message carrying tool_use blocks before the tools
run, and their results only after the batch finishes. Dying in between (client
disconnect terminating the function, a deploy, a slow tool) left history ending
on an unanswered tool_use, which the Messages API rejects on replay: every later
turn 400s, and agent_messages is append-only for the BFL trail, so nothing could
repair it. History is now patched on read by synthesizing is_error tool_results,
leaving the stored trail untouched.

check_and_increment_agent_quota is SECURITY DEFINER in public with a
caller-chosen p_user_id, so any authenticated user could drain a colleague's
minute/day budget and lock them out of every agent endpoint. A plain REVOKE
would break the limiter (all three callers use the user's RLS client) and, as it
fails open, silently remove the spend cap: the function now refuses to act for
anyone but the caller, while service-role connections keep passing an explicit
id.

The single reject route re-read status and then wrote unguarded, so losing the
race with commit's atomic pending -> committing claim stamped `rejected` over an
operation that had already posted a verifikat, invisible to the committing-state
recovery sweep. Guarded on status like bulk-reject already is; a lost race is
now a 409.

The sheet's Escape handler listened on window with no defaultPrevented or target
check while the sheet is deliberately non-modal, so pressing Esc to dismiss the
reject-reason Select inside an approval card, the command palette or any dialog
unmounted the sheet and discarded the conversation, the streaming turn and the
un-actioned proposal. It now yields to open overlays and to focus outside the
sheet.

Verified: 9526 unit tests pass, lint clean on touched files, guards pass, and
the new pg-real test proves the quota guard against real Postgres (attacker
raises 42501, victim counters stay at 0). The four unrelated pg-real failures on
this machine reproduce identically with these changes stashed.

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

* fix(assistant): close anon path on the quota RPC, order the ownership check ahead of writes

Review follow-ups on the hotfix batch.

The caller guard used auth.uid() alone, which is NULL for the `anon` role just
as it is for backend roles, so an unauthenticated caller holding the public anon
key (it ships in the browser bundle) could still pick any p_user_id and drain
that user's quota. The guard now keys on the request role: anon and
authenticated may only ever spend their own quota, backend roles keep passing an
explicit id. The default PUBLIC execute grant is revoked as a second layer, with
execute granted only to authenticated and service_role. Covered by a new pg test
for the anon path.

The ownership check ran after the onboarding.intake stamp, so a request that was
about to be rejected could still write intake_completed_at. It now sits directly
after the capability gate, ahead of every side effect and ahead of the company
and profile reads, which also makes a rejected request cheaper.

The tool-result repair matched ids anywhere in the history, but the API needs
results in the message IMMEDIATELY after the tool_use. A result persisted after
an intervening turn (two turns racing on one conversation) left a shape that
still 400s. The repair is now positional, and orphaned or late-duplicate
tool_results are dropped, since an unmatched tool_result is rejected just as an
unanswered tool_use is.

The Escape guard matched the Radix popper wrapper, which stays mounted when a
popper is force-mounted; it now requires data-state="open" so a closed popper
cannot block Escape for the rest of the session.

Both new route errors are Swedish, per the user-facing error rule.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:48:18 +02:00
Jakob Wennberg 1270b6daeb fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:49:10 +02:00