Commit Graph

126 Commits

Author SHA1 Message Date
Mattsson f1230282a9 feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings

A company running several bank accounts (main bank on A, company card on M,
both imported via CSV) could not route each account's bookings into its own
series: every bank_transaction booking took the single company-wide default
from default_voucher_series_per_source_type.

- cash_accounts.voucher_series (nullable, single letter): per-account override,
  editable under Inställningar → Bokföring → Verifikationsserier per bankkonto
  (new PATCH /api/cash-accounts/[id]).
- resolveCashAccountVoucherSeries(): step 2 of the resolution order
  (explicit pick → account override → per-type map → A). Wired into the book
  route and createTransactionJournalEntry, which covers categorize, the agent,
  pending operations and the v1 API.
- Booking dialog gets the series picker, seeded from the server via
  /voucher-sequences/next?source_type&cash_account_id so dialog and route can
  never disagree. An unresolved embedded picker omits voucher_series so a
  stray 'A' never overrides the account's series.

Scope: bank_transaction bookings only. Invoice settlements matched from the
bank keep their payment series; bulk-book resolves inside its RPC (see
DECISIONS.md).

Migration applied to staging as 20260902121420.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

* fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH

Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish
compliance review):

- Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series
  is a behandlingsregel that outranks the audited per-type map. New trigger
  audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so
  bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the
  audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in
  the report, pg-real test. Applied to staging as 20260902124513.
- Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a
  verifikationsserier_regler block with the resolution order and the two
  exceptions (invoice settlements, samlingsverifikat); the per-account mapping
  itself is in data/cash_accounts.json.
- Settings picker uses the same closed list as the manual verifikat form
  (presets plus letters already in use) instead of all 26 letters; strings
  moved to messages/sv.json and messages/en.json.
- /voucher-sequences/next applies the account override only for
  source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot
  show a series the entry will not get.
- Book route resolves the series from the account the row ends up on after a
  stranded-row repoint, not the stale one.
- PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a
  Postgres cast 500; the series lookup logs a warning when it fails open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:25:34 +02:00
Mattsson 4c76fb10d7 feat(transactions): "Ta bort underlag" detach action on a transaction (#2132) (#2144)
* feat(transactions): "Ta bort underlag" detach action on a transaction (#2132)

Wrong receipt pinned, no way back: the DELETE
/api/transactions/[id]/attach-document route and its tests already existed,
but nothing in the UI called it. This wires it up, frontend only.

- Inbox card and history list: "Ta bort underlag" in the row menu, shown only
  for writers on unbooked rows that carry a pin (canDetachDocument helper).
- Attach dialog: a small "Ta bort underlag" link beside the already-attached
  hint, the one place the app previously admitted a doc was pinned.
- Page: handleDetachDocument confirms (useDestructiveConfirm, warning), then
  DELETEs; 200 clears document_id in local state (list, dialog snapshot, and
  the inbox card's optimistic override via a -unlinked window event) and
  toasts; 409 renders the route's Swedish BFL message verbatim; other errors
  map through get-error-message.
- Strings under tx_detach in sv.json and en.json.
- Tests: gate hidden when booked / read-only / no pin / no handler; 409
  rendered unchanged; wiring and locale assertions.

Out of scope, follow-up: MCP detach tool (new pending-op type + CHECK
migration), detaching from the inbox for non-email docs, and clearing
invoice_inbox_items.matched_transaction_id on detach so the doc is offered
again by inbox-available.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg

* fix(transactions): clear the inbox back-link when detaching underlag (#2132)

Skeptic finding on PR #2144: DELETE attach-document nulled only
transactions.document_id and left invoice_inbox_items.matched_transaction_id
pointing at the transaction. propagateUnderlagForBookedTransaction selects
on exactly that column at categorize / book / bulk-book time, so the
detached receipt would have been re-anchored onto the new verifikation as
immutable underlag (BFL 5 kap 7 §), and the doc never reappeared in
inbox-available for re-matching.

The route now clears the back-link for the detached document, scoped to
items not yet consumed by a verifikat (created_journal_entry_id null),
mirroring the invoice-inbox extension's unmatch. Best-effort like the POST
side: the pin removal is the primary effect. Three DELETE tests cover the
filters, the no-pin case, and a failing unlink. DECISIONS.md and the PR body
record the accepted bulk-booked-row limitation in the history list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg

* fix(transactions): detach reports a failed inbox unlink instead of success (#2132)

Swedish compliance review on PR #2144: the inbox back-link cleanup was
fire-and-forget, so a failed UPDATE returned 200 while leaving exactly the
stale matched_transaction_id that re-anchors a detached document onto the
next verifikation (BFL 5 kap 6-7 §).

The unlink is now scoped by transaction only (the unique index on
matched_transaction_id means at most one item points here, and a stale item
from the replace path would re-anchor just the same), runs even when nothing
was pinned so a retry is idempotent, and a failure answers 500 with an honest
Swedish partial-failure message, mirroring the POST side's propagation
failure. Tests updated accordingly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg

* fix(transactions): release inbox back-link before a compare-and-set pin clear (#2132)

Review findings on PR #2144, one pass:

- CodeRabbit (major): DELETE cleared the pin and then released the inbox
  back-link scoped by transaction, so a POST landing in between could end up
  as "new doc pinned, its inbox item unlinked". The release now runs FIRST,
  and the pin clear is a compare-and-set on the document that was read
  (.eq document_id, or .is null when nothing was pinned). Zero rows answers
  409 "ändrades samtidigt" and keeps the newer pin. A failed release returns
  500 before anything changed, so a retry is trivially idempotent.
- Compliance swarm (A.8.15): the unlink failure log carried the raw driver
  error; it now logs errorCauseTag() only.
- CodeRabbit docstring check: JSDoc on handleDetachDocument.

Tests: order of the two writes, CAS filters for both pinned and empty
states, 409 on concurrent re-attach, coded-cause logging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:29:22 +02:00
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00
Jakob Wennberg 4e1eb3d662 fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643)

A broken bank reconnect leaves cash_accounts rows that share the live
account's IBAN (held by a revoked connection, or demoted to manual by the
#916 fix). Three consequences are fixed here:

- Problem 4 (silent mis-booking): the own-account transfer detector paired
  with such an orphan and proposed its ledger as the counter-account, and a
  counterparty template learned from that result replayed as 1940/1931 in
  the booking dialog. The detector now tolerates several rows on one IBAN,
  never pairs with the transaction's own row, a disabled row, or a revoked
  holder; the mapping engine drops a "transfer" whose counter equals the
  settlement account; suggest-categories withholds learned suggestions that
  reference an orphaned ledger; and both commit paths (POST
  /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject
  with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are
  only refused in the COUNTER position: a stranded row still settles on its
  own ledger, and a manual account without a live IBAN twin is never
  treated as orphaned, so transfers between two live accounts keep booking.
- Problem 1 (match dialog): the ranked unmatched-entries path also offers
  vouchers booked on sibling ledgers of the same IBAN, and manualLink
  accepts a voucher line on a sibling ledger. When it does, the same locked
  UPDATE re-points transactions.cash_account_id to the live sibling row
  (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the
  account-keyed reconciliation does not count a cross-account link as an
  imbalance on both ledgers.
- Problem 3 (naming): allocatePsd2LedgerAccount names the chart account
  BAS-style (BAS reference name for a standard slot, else "Bankkonto
  <CUR>") instead of the ASPSP-reported holder name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643)

One in-memory topology (cash_accounts rows + bank_connections status) now
defines "live", "orphaned" and "same physical account" for the transfer
detector, the match/link flows and every commit guard, so a proposal is
never made that a guard later rejects.

- Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban
  treats the transaction's own IBAN as "not a transfer": every same-currency
  row on that IBAN is the same physical account, whichever is live, so
  interest stamped with the own IBAN never pairs with a twin (two active
  rows, a demoted-manual twin, or a live twin of a stranded row). Only a
  pocket in another currency on that IBAN can still pair. guardCounterLegs
  refuses a same-IBAN same-currency twin in the counter position on every
  commit path, even when both rows are active.
- Finding 3: with several surviving candidates (currency pockets with no
  discriminator, or two active twins) the finder returns null instead of
  picking the lowest ledger, which is what the pre-PR lookup did.
- Finding 5: the finder drops every row in the orphaned set, the same
  predicate the commit guards use (demoted-manual twins included).
- Finding 9: "live" means enabled + connection status 'active'; an
  expired/error twin of a live row is orphaned, a lone expired connection
  (re-auth window) is not.
- Finding 2: siblings are keyed on (normalized IBAN, currency) in
  describeCashAccountSiblings and the unmatched-entries route, so a SEK
  transaction can no longer link to a voucher whose only bank leg is on the
  EUR pocket of the same IBAN; manualLink rejects that as before.
- Finding 8: manualLink re-points a row only when the voucher sits on the
  LIVE sibling and the own row is not live; the reverse direction links
  without moving the row.
- Finding 7: the v1 REST categorize route runs the same guardCounterLegs
  check after account_override and returns
  TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through
  categorizeMatchedTransaction, already covered.
- Finding 10: a learned template whose stale 19xx leg is a twin of the
  settlement row is rewritten to the settlement account (it is the bank
  leg, not the counter) instead of refused; suggest-categories exempts each
  transaction's own settlement ledger before withholding a suggestion. The
  error message now covers both the twin and the disconnected case.

Tests pin each behavior (service, detector, manualLink, unmatched-entries,
dashboard and v1 categorize routes, suggest-categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-2 review findings (#1643)

1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology
   now keys the live twin on normalized IBAN plus currency (the rule every
   other "same physical account" check in the PR already used), so a
   manual or deselected GBP/EUR pocket beside a live SEK pocket of a
   multi-currency account is never orphaned, still pairs in the transfer
   detector and is accepted as counter at commit. Twin computation is
   shared (twinLedgersOf).
2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is
   a twin of the transaction's own row is rewritten to the settlement
   ledger in the offered suggestion instead of being withheld; only a true
   counter-position orphan (or a twin that would book the settlement
   ledger against itself) is withheld. One topology load per batch
   (loadCounterLegTopology).
4. The free-form dialog path (POST /api/transactions/[id]/book) gets a
   line-level guard (guardBookedCounterLines): a 19xx line that is a twin
   of the transaction's own row or an orphaned ledger, alongside the
   settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT.
   Only runs when the lines touch two distinct 19xx ledgers. The twin
   rewrite in suggest-categories (2) covers the both-active shape before
   the dialog is even opened.
5. manualLink re-points the row onto the sibling ledger the voucher was
   booked on whenever the sibling is live or the own row is not (both-live
   twins and both-dead rows included); only a live row whose voucher sits
   on a dead sibling links without moving. unmatched-entries now uses
   describeCashAccountSiblings and does not offer dead-sibling vouchers to
   a live row.

DECISIONS.md: the PR's existing review follow-up line amended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-3 review findings (#1643)

1. Revoked-held rows are no longer orphaned unconditionally. A row whose
   connection is revoked is orphaned only under the twin rule (not live
   AND a live row shares its normalized IBAN + currency), so a
   disconnected-but-real account (the company's only 1930, or two real
   accounts on one revoked connection) stays pairable by the transfer
   detector and bookable as counter on every guarded path. Tests cover
   the no-twin case for getOrphanedCounterLedgers,
   findPairableCashAccountByIban, detectOwnAccountTransfer,
   guardCounterLegs and guardBookedCounterLines; the existing revoked
   tests now use a twin shape.
2. manualLink / unmatched-entries decide the re-point on the destination:
   a new shouldRepointToSibling moves onto a live sibling, or onto a dead
   one only when the own row's holder is gone (released: bank_connection_id
   null or revoked) and no sibling is live. An expired/error/pending own
   row links without moving. SiblingCashAccount gains `released`. Tests:
   expired own row + demoted twin links without moving and the twin's
   vouchers are not offered.
3. loadCounterLegTopology is exercised directly: settlement ledger and
   twins, other-currency pocket, null/unknown ids, cache, orphan set
   equal to guardCounterLegs' refusals on the same fixture, lookup failure.
4. guardBookedCounterLines docstring and the /book route comment now state
   that only the two-cash-legs shape is inspected; a single hand-typed
   19xx line is not (covering it would cost a cash_accounts lookup on
   every ordinary booking). DECISIONS.md lines amended accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-4 review findings (#1643)

1. Same-connection re-registration twins (the dominant prod shape): two
   enabled rows on one active connection sharing (IBAN, currency) are now
   told apart by balance_updated_at; only the most recently synced row is
   live, the other is a stale twin (orphaned as a counter, never a
   re-point destination, and the transfer detector pairs with the syncing
   row alone). Rows with no stamp or the same stamp both stay live.
2. POST /book: a single 19xx line that is a sibling ledger the row should
   move to (the live twin of a stranded row) re-points cash_account_id in
   the same locked UPDATE that links the voucher, mirroring manualLink.
   guardBookedCounterLines returns { refusedLedger, repointCashAccountId };
   an ordinary booking pays one PK read of the own row.
3. manualLink refuses the link (success:false, Swedish error) when the
   voucher sits only on a dead sibling instead of writing a cross-account
   link with a server-side warn; the REST and MCP link callers reach it
   without the unmatched-entries filter.
4. manualLink judges a voucher touching several sibling ledgers on the
   best of them (a live sibling, else the first the row may move to)
   instead of the first line PostgREST returns.

Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route;
the two DECISIONS.md lines for #1643 amended in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-5 review findings (#1643)

1/2/5. Same-connection twin liveness no longer ranks on
   cash_accounts.balance_updated_at (a connect-time snapshot the sync
   never refreshes, inverted on prod in 4 of 5 stamped groups). The live
   row is the one whose external_uid the bank still lists in
   bank_connections.accounts_data (rewritten on every sync); no listing,
   both listed or neither listed keeps both rows live (round-3 behavior).
   getConnectionStatuses selects accounts_data in the same query.
3. guardBookedCounterLines single-19xx-line shape: a twin the row may not
   move to (dead or disabled) is refused with
   TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only
   bank leg on the dead ledger; an unrelated 19xx line still posts as
   typed. Route test added.
4. Disabled cash_accounts rows are never siblings, so neither manualLink
   nor /book re-points a transaction onto a deselected row; a voucher
   booked only there is refused as a cross-account link.
6. PR body rewritten to the final rules; DECISIONS.md round-4 line
   amended (signal correction, /book refusal, disabled siblings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643)

CashAccount.external_uid is nullable in the shared type; the same-connection
twin rule now skips null uids instead of passing them to Set.has, which
failed the strict type check in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643)

Two enabled rows on one active bank connection sharing (IBAN, currency)
are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at
and round 5 on external_uid presence in bank_connections.accounts_data;
each was verified against prod and each was contradicted by it (ingest
routes by the accounts_data entry's ledger_account, which in two groups
points at the OLD row, so the "stale" row is the one still being fed).

Restores the round-3 behavior: neither twin is orphaned, the transfer
finder returns null when both survive, no guard refuses either, and
shouldRepointToSibling treats both as live siblings. No replacement
signal; how to model the shape is a founder decision (PR #2010 review).
getConnectionStatuses no longer selects accounts_data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:26:47 +02:00
Jakob Wennberg f0af4ad4ee fix(transactions): categorize fails closed when the verifikat cannot be created (#1990)
* fix(transactions): categorize fails closed when the verifikat cannot be created (#1947)

Booking into a locked period refused the verifikat but still wrote
is_business/category, so the row left "Att bokföra" and the nav badge
while journal_entry_id stayed NULL (canonical worklist predicate:
is_business IS NULL). The verifikat is the booking: when it cannot be
created nothing is written and the request returns a typed 409
TX_CATEGORIZE_JOURNAL_ENTRY_FAILED (Swedish reason preserved,
details.cause = underlying code); a null engine return maps to 400
NO_OPEN_PERIOD_FOR_DATE. Same shape on the dashboard route, the v1
single route and per item in v1 batch-categorize. journal_entry_error
stays in the 200 body, always null, for client compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(transactions): fail closed on the engine's null return in the MCP/bulk door too

Review findings on #1990: categorizeMatchedTransaction (pending-op
approval, Underlag bulk-book) still wrote is_business/category with
journal_entry_id NULL when createTransactionJournalEntry returned null
(closed year or missing period return null without throwing), recreating
the exact #1947 stranding while the tool reported success. The core now
refuses before the transactions update with a structured 400 whose
errorCode (PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE, told apart via
checkPeriodLock) flows into result_data.error_code; the bulk driver
skips such items with reason no_open_period.

The dashboard route's null guard gets the same disambiguation: a closed
covering year answers PERIOD_LOCKED (reason period_is_closed) instead
of claiming the rakenskapsar does not exist, and the thrown-error branch
now pairs messageSv with messageEn per the errorResponseFromCode
contract. TX_CATEGORIZE_JOURNAL_ENTRY_FAILED message_en no longer
embeds API-doc prose (details.cause guidance lives in remediation).
DECISIONS line corrected: the MCP door was fail-closed only for thrown
engine errors, not the null return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:25:11 +02:00
Jakob Wennberg 1a27b5bd4a fix(auth): route document integrity, transaction delete/list and agent categorize through withRouteContext (#1926)
Two handlers hand-rolled supabase.auth.getUser() and therefore skipped the
MFA (AAL2) gate on hosted: DELETE /api/transactions/[id] and
GET /api/transactions. Both sat next to a sibling handler that was already
wrapped, and the raw-route-auth ratchet exempted a file as soon as any
withRouteContext call appeared in it, so they were never flagged.

GET /api/documents/[id]/integrity and POST /api/agent/categorize called
requireAuth() directly (MFA enforced, but no request id, no completion log,
no canonical error envelope). All four are now withRouteContext handlers
with identical company scoping and responses; the transaction delete keeps
its viewer rejection via requireWrite.

The guard now judges each top-level export segment of a route file on its
own, so a wrapped handler no longer exempts a hand-rolled sibling. Baseline
is unchanged (mcp-oauth/authorize remains the one grandfathered file).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:34:31 +02:00
Jakob Wennberg 7cf15a105f fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account

A transaction with no cash_account_id booked its bank leg on the
hardcoded 1930 from the standard templates and category mappings even
when the company's only bank account is e.g. 1920 (PlusGiro), while the
booking dialogs previewed the right account via the client-side
resolveAccount fallback. resolveSettlementAccount now mirrors that
fallback: with a NULL cash_account_id it lists the company's enabled
cash accounts and, when EXACTLY ONE matches the transaction's currency,
settles there; zero or several candidates keep the 1930 fallback. The
explicit-cash_account_id branch (including its throw-on-error path,
issue #842) is byte-identical. Transaction currency is threaded into
the categorize, batch-categorize, pending-operation edit, MCP staging,
and invoice-inbox call sites; other callers get the SEK default.

Forward-only: historical wrong verifikat are corrected only via the
existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md).

Fixes #1722

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

* test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups

The merge of main (PR #1828) into this branch combined two changes that each
add one query to the categorize commit flow; the strictly ordered queued mock
in the allow_duplicate test needed the cash_accounts listing entry inserted
between the period lookup and the pre-FY clamp lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:26:08 +02:00
Jakob Wennberg 8e3015e541 fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.

Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.

Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
  OPEN unlocked fiscal period with entry_date = period_start and appends
  "Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
  verifikationstext. Interior gaps, future dates, and a closed/locked
  first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
  calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
  forsta dag (<date>)" for pre-FY dates instead of proposing a
  pre-registration year; "Skapa rakenskapsar" remains for the other
  no_period cases.

No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.

Fixes #1825


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:50 +02:00
Mattsson 43cde6deb9 fix: unignore transactions during categorization (#1683)
Fixes #1660
2026-08-19 11:00:02 +02:00
Mattsson ced6f1c65b fix(reconciliation): await the pre-existing matched/storno match-log writes (#1606)
Final Swedish-review finding (approved by Emil): the six fire-and-forget
logMatchEvent calls that predate this branch in the four match routes are
now awaited, matching the rest of the PR and the DECISIONS claim that
every audit write is awaited. logMatchEvent never throws; on serverless
an unawaited promise can be frozen when the response returns, silently
dropping the behandlingshistorik row (BFNAR 2013:2 kap 8).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:35:14 +02:00
Mattsson 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

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

* fix(reconciliation): resolve PR review findings in one pass

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

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

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

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

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00
Jakob Wennberg 0d3ba5268d fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3

The booking-time duplicate guard missed the most common bank-fee twin
shapes:

- G1: the sibling scan matched on the EXACT date only, so a duplicate
  import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was
  invisible. The scan now uses a +-3 day window with a deterministic
  ranking where exact-date candidates always outrank drifted ones
  (force=true re-detection stays bound to the reviewed candidate).
- G2: booked-ness required transactions.journal_entry_id, so bulk-booked
  (transaction_voucher_links) and multi-allocated (invoice_payments /
  supplier_invoice_payments) siblings read as unbooked. The scan now
  batch-fetches the anchor rows and resolves the verifikat via
  getPrimaryJournalEntryId (is_transaction_booked semantics).
- G3: the ledger scan excluded every voucher linked to any transaction,
  so a voucher booked from a date-drifted duplicate row escaped BOTH
  halves and the booking proceeded with no warning. A voucher whose
  linking transaction itself matches the target (same ore in the same
  currency, compatible cash account, date in the window) is now returned
  as the twin with transaction_id set.

All candidate picks keep explicit total-order tiebreakers so a force
re-detect returns the same candidate the user reviewed, and the
SEK-or-null amount contract is unchanged.

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

* fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog

The duplicate dialog hid its match action for sibling-transaction
candidates (canMatch required transaction_id === null), so the user who
most needed steering saw only 'Bokfor anda'. manualLink explicitly
allows N:1 links, so the match action is now offered for both candidate
kinds. Sibling candidates get question-form body copy ('vill du matcha
mot verifikatet i stallet?') and an additional 'Ignorera transaktionen'
action via the existing POST /api/transactions/[id]/ignore, which is the
correct resolution when the row itself is a duplicate import (matching
would double-count the bank side, booking the ledger side).

Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a
destructive toast with no way forward:

- the counterparty-template branch of handleQuickReviewConfirm now sets
  the shared duplicateWarning state exactly like runCategorize, with the
  force retry bound to the reviewed candidate's voucher
- BankReconciliationView's quick-book now opens the same dialog, with
  match/ignore refreshing the reconciliation lists

New sv/en strings: dialog_duplicate_body_sibling,
dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level
parity tests pin the 409 routing and the dialog affordances.

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

* fix(transactions): duplicate guard on the bulk-book samlingsverifikation path

/api/transactions/bulk-book never called detectBookingDuplicate, so a
batch containing an already-booked twin minted a second verifikat with
no warning. The route now runs the shared per-tx guard before the RPC,
with intra-batch exclusions (the other selected txs are distinct events
the user picked, and the link-existing target voucher is the batch's own
destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the
candidate and the flagged tx id.

BulkBookDialog routes the 409 into DuplicateBookingDialog for review
(view voucher / cancel / book anyway) instead of a dead-end toast;
'Bokfor anda' re-runs the batch with force=true. On force the route
re-detects and records each dismissed candidate as
BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2
kap 8), parity with the /categorize bypass. Detection failures stay
fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions)
bypasses this route and remains unguarded; guarding inside the RPC needs
a migration and is out of scope here.

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

* fix(transactions): gate the duplicate-dialog ignore hint on the action being present

The sibling body copy mentioned ignoring the row, but two render sites
(the manual booking form and the bulk dialog) show sibling candidates
without the ignore action. The guidance now lives in a separate
dialog_duplicate_ignore_hint string rendered only when the Ignorera
button itself renders, so copy never points at a button that is not
there.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:55 +02:00
Jakob Wennberg 1eebb75269 feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.

New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.

UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.

New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:20:14 +02:00
Mattsson d9dddba682 fix(transactions): anchor the pinned document to the verifikat on booking (#1560)
A document pinned to a transaction (transactions.document_id) with no
unconsumed inbox item was never anchored onto the verifikat when the
transaction was booked: document_attachments.journal_entry_id stayed
null and every underlag surface reported "Underlag saknas" for a
booking that HAS its underlag (attach-before-book via the manual
booking dialog, the 2026-08-13 user report).

PR #1547 already routed /book, bulk-book and categorize through the
shared propagateUnderlagForBookedTransaction helper, but that helper
only walked matched inbox items. This adds a pinned-document leg to the
helper, so all booking paths anchor the pin in one place:

- the pin is read fresh inside the helper (not from the caller's
  pre-booking snapshot) so a concurrent attach is still anchored
- same guard semantics as inbox docs, via the extracted
  anchorDocumentToJournalEntry: no-op when already anchored to this
  verifikat, never steal another verifikat's underlag, log-and-continue
  on failure (the booking is already posted; a re-run repairs the link)
- the bulk-book RPC already anchors pins atomically, so the leg no-ops
  there

Route tests cover the three plan cases: pinned doc anchored, matched
inbox item stamped, and propagation failure never failing the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:50:03 +02:00
Mattsson 8d56219c31 fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever

A matched inbox item only left the active inbox when
created_journal_entry_id was stamped, and only categorizeTransactionCore
stamped it. Booking the matched transaction through any other path (the
/book dialog route, bulk-book, link-to-existing-voucher) or matching a
receipt to an already-booked transaction (receipt hunt approvals,
attach-document, match-transaction) left the item "linked" forever,
pointing at a transaction that had already left the transactions work
list. Todays hunt fix (#1524) turned this July-old gap into a visible
flood of stuck items.

Two-part fix, because stamps alone cannot cover the reported case:
created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book
samlingsverifikat only one of N matched items can ever carry it.

Write side: lib/transactions/inbox-underlag.ts is the shared
implementation all paths now call. It links matched items' documents to
the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the
verifikation) and stamps created_journal_entry_id best-effort (CAS on
null, unique_violation tolerated). Wired into categorize-core (replacing
its inline block), /book, bulk-book, linkTransactionToJournalEntry, both
attach paths (REST + pending-operation), and the inbox match-transaction
handler. The attach paths and the doc-conflict guard also resolve
bulk-booked transactions through transaction_voucher_links, which they
previously treated as unbooked.

Read side: GET /items (and /items/:id) enrich matched-but-unstamped
items with matched_transaction_journal_entry_id, and the workspace
derives "booked" from it. This is what clears the stuck rows already in
prod without a status backfill, and what covers the N-1 samlingsverifikat
items the UNIQUE constraint refuses to stamp. Bulk-book selection
filters exclude such items so "Bokfor valda" no longer offers 409 fodder.

scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs
the historical document->verifikat links the old paths never made.

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

* fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik

Both from the Swedish accounting compliance review.

The consumed-stamp is now conditional on the underlag actually
referencing a verifikat: stamping over a failed document link hid the
item from the .is('created_journal_entry_id', null) query forever,
leaving a posted verifikation without its underlag reference
(BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed
link now leaves the item unstamped so re-runs and the backfill can
finish the job; a document preserved on another verifikat still counts
as settled.

The backfill script now appends an InboxUnderlagBackfilled event per
repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass
repair touching underlag-to-verifikat linkage leaves a changelog trail
distinguishing it from the original booking action.

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

* refactor(inbox): backfill writes behandlingshistorik through the shared appender

From the Swedish accounting compliance review round 2: a hand-rolled
processing_history insert in the backfill script could drift from the
shared row shape and skip the PII validation. appendProcessingHistory
now delegates to appendProcessingHistoryWithClient, which takes a
caller-supplied service-role client, so standalone scripts write
behandlingshistorik through the exact same code path as the app
(BFNAR 2013:2 kap 8: one reconcilable change log across writers).

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

* fix(inbox): leave the item unstamped when its document belongs to another verifikat

Swedish accounting review round 3: refusing to steal the document was
right, but stamping the item consumed anyway hid the fact that the
transaction's own verifikat ended up with no underlag reference from it
(BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves
created_journal_entry_id null so the mismatch keeps surfacing for
reconciliation, same posture as a failed link.

Also documents in the backfill script header why its writes cannot land
in locked periods: linkToJournalEntry's UPDATE is guarded by the
enforce_period_lock DB trigger, which fires for service-role writes too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:49:20 +02:00
Jakob Wennberg 7cf0e34434 feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines

Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP
beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium
(SLF 1991:687). The item-based debit-only form could not express the
self-balancing pair, so users had to hand-edit the verifikat.

- new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source,
  re-exported by the bokslut calculator), isSlpPensionAccount (741x),
  generateSlpLines (7533 D / 2514 K, nets to zero)
- migration adds supplier_invoice_items.apply_slp boolean default false
- registration, cash, and privately-paid generators inject the pair for
  flagged 741x items, mirroring the reverse-charge injection; the balance
  guarantees keep 2440/1930/2893 at exactly the invoice total; the credit
  note generator reverses the pair (7533 K / 2514 D)
- privately-paid balance guarantee now subtracts existing credits so the
  SLP 2514 leg never inflates the owner account
- schema field apply_slp + guards in all create paths (main route, inbox
  convert, v1 REST, pending-operations executor): 400
  SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400
  SI_CREATE_SLP_ACCRUAL combined with periodisering
- form: advisory hint on unflagged 741x rows with one-click opt-in and a
  quiet confirmation line when applied; totals box untouched (the invoice
  total stays the payable); AB review preview injects the same pair via
  the same generator for parity
- year-end double-count guard: calculateSarskildLoneskatt subtracts SLP
  already posted to 7533 during the year (floored at zero) so bokslut
  never provisions flagged premiums twice

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

* chore(api-skill): regenerate suppliers reference for apply_slp

The apiskill:check CI gate requires the generated accounted-api skill to
stay in sync with the endpoint registry after the apply_slp addition.

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

* fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal

Review findings on the SLP PR:

- v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so
  createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514
  pair booked at registration (it previously stood forever and the
  year-end netting under-provisioned). The flag is also copied onto the
  created credit-note items for parity with the web credit route.
- v1 mark-paid: the items sub-select now includes apply_slp, so a
  kontantmetoden payment via v1 books the cash entry WITH the SLP pair,
  matching the web mark-paid.
- v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag
  is readable back through the public API.
- credit-note SLP base is abs of the SIGNED sum of flagged line_totals,
  not per-item abs: a mixed-sign flagged original (+10000/-2000) booked
  SLP on 8000 at registration and now reverses exactly that, not 12000.
  The expense-bucket per-item abs convention is untouched.
- kontantmetod bank-match preview appends the same generateSlpLines pair
  the POST books, so the approved lines equal the committed lines.
- MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts
  apply_slp (optional boolean), plumbs it into the staged operation's
  items, and rejects non-741x resolved accounts at staging time with the
  bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts.
- DECISIONS.md: five entries for today's decisions.

Every behavioral fix has a test verified to fail without it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 20:52:47 +02:00
Jakob Wennberg 17d48ebb84 fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path

A never-booked kontantmetoden invoice can only be settled by the generated
cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and
that entry always books the FULL invoice: it takes no payment amount. Three
payment surfaces still let partial payments through to it, corrupting books:

- settleInvoicePayment dropped the fully-paid term entirely, so a partial
  payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized
  revenue, over-declared output VAT, and a bank debit that did not match the
  money received.
- The dashboard and agent match-transaction paths fell back to an
  accrual-style clearing entry against an EMPTY 1510: negative receivable,
  no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own
  receipt period). The comment claimed the credit "gets resolved on final
  payment", but the cash builder never touches 1510 and books the full
  total, so the final payment double-debited the bank instead.
- The supplier routes had no full-settlement term at all, so a partial
  payment booked the full expense + input VAT.

Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts)
rejects generated cash entries unless the payment settles the invoice in
full from a fully unpaid state, wired into all six POST surfaces, the agent
commit paths, and the three preview routes (so dialogs cannot propose a
verifikat the POST refuses). New bilingual error codes
INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED.
Invoices booked at issue are unaffected: their partial payments keep the
normal 1510/2440 clearing path.

The v1 match-invoice route already had this guard (VALIDATION_ERROR); its
behavior is unchanged. Proper per-installment recognition (proportional
revenue + moms per receipt) is the follow-up feature that would lift this
restriction.

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

* docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing

Compliance-review finding: the section is the old ML 1994:200 numbering; in
ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt.
The substantive rule (bokslutsmetoden reports moms at payment, per
installment, except at year-end) is unchanged and stated without a section
cite until the current-law section is verified. Comments and cookbook prose
only; no behavior change. Also fixes the two pre-existing occurrences.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:11 +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
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 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 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
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 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
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
Mattsson 53e343ee92 Bug/invalid imports (#1146)
* feat: add Accounted MCP namespace

* fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice

The missing-underlag surfaces only accepted a document directly linked to
the entry, so payment verifikat for supplier invoices (doc on the
registration entry per design) and entries whose doc was pinned to the
bank transaction before matching were falsely flagged; opening the entry
showed the referenced doc and cleared the warning client-side, and it
came back on reload.

- verifikat_without_documents + transactions_without_documents now treat
  an entry as covered when a supplier invoice referencing it (registration
  or payment FK, or a supplier_invoice_payments row) carries a document
  anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till
  underlag; anchoring required because the WORM deletion guards key on
  document_attachments.journal_entry_id)
- match-supplier-invoice routes (dashboard + v1) propagate the
  transaction's pinned document onto the payment verifikat, mirroring the
  categorize route; migration backfills rows already written (open
  unlocked periods, company-guarded, never steals a linked doc)
- /api/documents/counts, the transactions-page badges, the bulk "Inget
  underlag krävs" count and the push-notification scheduler share the
  same reference-aware predicate, so every surface agrees with the RPC
- counts route validates journal_entry_ids as UUIDs (they are
  interpolated into a PostgREST or-filter)

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

* fix(transactions): align table columns flush with page edges

Collapse the checkbox gutter column to zero width and hang the
hover-revealed checkbox/expand chevron in the page margins, drop the
outer padding so DATUM sits flush left and STATUS flush right, and
tuck the overflow-menu dots under the middle of the STATUS header.
Applied to both the inbox and history tables so they stay identical.

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

* fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation

The ARL 5:8 roll-forward note recomputed depreciation from its own
day-based linear formula (365.25/12 month length, non-inclusive day
count, linear only), drifting ~20 kr per year per asset from the
ledger-driven resultat- and balansrakning and misstating non-linear
methods entirely. Note figures now come from posted
depreciation_schedules rows (the same source disposeAsset reverses),
falling back to the engine's computeAnnualDepreciation when nothing is
posted; pre-onboarding opening balances iterate prior years through
the engine. Adds a note-vs-trial-balance tie-out warning (accounts
1000-1299, over 1 kr) surfaced before download.

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

* refactor(stripe): move connect and sync surface from settings to import page

Stripe's transaction feed is a continuous import source in the same
category as the PSD2 bank connection, so its connect/sync surface now
lives on the import page as a source card (mode=stripe), gated
"kommer snart" on hosted like before; self-hosted keeps the full panel.

- Import page: Stripe card after Koppla bank, renders the existing
  StripeSettingsPanel via the settings-panel registry
- OAuth callback and panel cleanup return to /import?mode=stripe
- Settings > Betalningar retired: nav item removed, route redirects,
  PaymentsSettingsContent deleted, legacy ?tab=payments mapped
- New import.stripe_* strings in sv+en; dead settings_nav.payments removed

Crons and sync logic unchanged; payment-link settings stay in the
invoicing section.

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

* fix(underlag): paginate missing-underlag cron and harden doc-surface queries

Resolve PR review findings on bug/invalid-imports:
- notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows
  the capped reads produced false "saknade underlag" notifications
- bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list
  stays under the PostgREST URL limit
- bulk-missing + transactions page: UUID-guard the .or()-interpolated id
  lists, matching documents/counts
- match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on
  the non-fatal doc-link warning
- well-known/oauth-protected-resource: document the tool_namespace allow-list
- messages/en: reword stripe_description
- DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls

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

* fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:03:50 +02:00
Jakob Wennberg 4a0b524fbb fix(categorization): connect card descriptors to counterparty history (#1095)
* fix(categorization): connect card descriptors to counterparty history

suggest_categories returned no signal for recurring card merchants
(reported: Anthropic booked to 5420 fourteen times, zero suggestions).
Three compounding causes, all fixed:

- normalizeCounterpartyName() now reduces card-network descriptors to
  their merchant segment ("ANTHROPIC* CLAUDE SUB SAN FRANCISCO" ->
  "anthropic"; "PAYPAL *SPOTIFY" -> "spotify"), so monthly per-charge
  tails stop splintering one merchant into unmatchable variants. SQL
  mirror normalize_counterparty_key() updated in lockstep (migration
  20260721140000), keeping the ledger-context template join exact.
- New token_subset match tier bridges templates learned from manual
  bookings ("Claude Dec" -> "claude") to bank descriptors containing
  the token, and card-core descriptors to legacy splintered templates.
  Guarded by a distinctive-token filter so generic/geo words never
  match on their own.
- Merchant history falls back to description when merchant_name is
  null: card purchases never carry merchant_name, so the history path
  was structurally blind to exactly the transactions that need it.
  History keys now share the counterparty-template normalization and
  the 200-row window is ordered by recency.

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

* fix(categorization): guard single-token matches, anchor history on original_description

Review follow-ups (CodeRabbit on #1095):

- token_subset tier: a single shared distinctive token now also requires
  occurrence_count >= 3 on the template, so a template named after a
  common word or first name (one prior booking) cannot vacuum up
  unrelated transfers ("SWISH ANDERS JOHANSSON"). Multi-token agreement
  stays unrestricted; the Claude/Anthropic case (14 bookings) is
  unaffected.
- merchant history keys on original_description ?? description: the raw
  bank descriptor is immutable while description is a user-editable
  working title, so renaming a transaction no longer severs its history
  link for future recurring charges.

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

* chore(migrations): re-timestamp card-descriptor migration after prod moved past it

Prod applied 20260721144311 (#1101) through 20260721201747 (#1104) while
this PR was open; 20260721140000 would sort before them and risk being
skipped by out-of-order auto-apply at merge. Not yet applied to prod, so
renaming is safe; the preview branch re-applies idempotently
(CREATE OR REPLACE).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:58:45 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
Mattsson 87f0d5af48 fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076)
* fix(errors): close remaining raw-message leaks after #1048 (#337)

Follow-up to PR #1048. No user-visible toast or response field can now
carry a raw engine or DB message; everything maps through getErrorMessage
or the structured-errors registry.

- get-error-message: only normalize a code-carrying Error instance into
  the structured path when the registry knows the code; unknown codes
  (Node system errors, stray third-party codes, Error-wrapped Postgres
  SQLSTATEs) fall through to pattern match, Swedish check, Postgres map
  and the status/context/generic fallbacks instead of returning the raw
  message. New Swedish-detection pattern for "ar last" phrases and a
  known-pattern row for "already has a journal entry".
- structured-errors: add CANNOT_EDIT_NON_DRAFT (409) and
  MANDATORY_DIMENSION_MISSING (400) rows, plus common Node network codes
  (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, EPIPE) as
  retryable 503 transients with a Swedish message.
- pending-operations commit + bulk-commit routes: map executor error
  strings through getErrorMessage before responding (raw stays in logs);
  Swedish passes through, English falls to status-appropriate Swedish.
- pending page: toast via getErrorMessage, fixing raw English toasts and
  "[object Object]" for structured envelopes on commit/bulk/reject.
- transactions book + journal-entries routes: untyped catch and DB list
  errors no longer return err.message; mapped or static Swedish instead.
- invoice send + issue-credit-note: partial_failures reasons are now
  Swedish (raw provider/DB text logged, never returned).
- Tests: new unknown-code/Error-instance suite, registry rows asserted,
  route tests updated off the pinned raw-English expectations.

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

* fix(skatteverket): target the räkenskapsår for yearly VAT redovisningsperiod

A yearly filer with a broken fiscal year has a Skatteverket period ending
in its FY-end month, not December, and the panel's year state is never
maintained in yearly mode (the year picker is replaced by the
räkenskapsår selector), so calls targeted the wrong period even for
calendar-FY companies filing after year end. The selected fiscal period
now rides through the whole chain: panel query strings, draft/validate/
submit bodies, buildMomsuppgift (which resolves the FY bounds so the
period id and the figures describe the same räkenskapsår), and the
staged-commit path. MCP callers without a fiscal period keep the
calendar fallback.

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

* feat(deadlines): group same-day skattekonto deadlines into one card

Moms, AGI and preliminärskatt legally share the skattekonto date (den
12:e), so a small monthly-moms employer saw 2-3 near-identical rows per
month. Two or more pending system rows of the skattekonto family on the
same due date now render as one grouped card with the date block once
and each obligation as a sub-row keeping its own confirm-to-complete
flow. Presentation only: rows, statuses, ICS feed unchanged.

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

* feat(deadlines): KU + ROT/RUT + long-tail opt-in deadlines, rolling horizon

Follow-ups from the #1028 audit left out of the #1057-#1060 fix stack,
each with its own condition modeling:

- kontrolluppgifter (KU10/KU20/KU31), due 31 Jan (SFL 24 kap. 1 §):
  opt-in flag suggested from ledger signals (2898 utdelning, 2393/2893
  ägarlån; deliberately not 2091, see DECISIONS.md), AB only, mirroring
  the #1059 EU-sales suggest-and-confirm pattern.
- rot_rut_begaran, due 31 Jan after the payment year (Lag 2009:194
  8 §): rows generated only for years with actually PAID ROT/RUT
  invoices, resolved inside the generator; invoice-derived suggestion.
- Long tail, explicit opt-in ('Fler deadlines'): OSS quarterly and IOSS
  monthly with a skipBankingDayAdjustment config flag (EU-law dates
  stand on weekends), Intrastat (10th banking day of the following
  month), punktskatt (ordinary skattedeklaration schedule), and
  fyllnadsinbetalning (12th of 2nd month over 30k / 3rd of 5th month,
  SFL 62:8 + 65 kap.). Kvarskatt deferred: needs a slutskattebesked
  date the app does not hold.
- Rolling generation horizon: recurring types ~6 months ahead, annual
  12 months, mirrored in the backfill expectation keys so the nightly
  cron never thrashes; regeneration now preserves manual in_progress
  status; one-time cleanup migration removes existing far-future rows.

Migrations also applied to the staging branch, together with the
previously missing 20260717xxxxxx deadline migrations (staging had
drifted and lacked dismissed_at).

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

* fix(arsredovisning): keep narrative editable after year-end close

The narrative save endpoint refused writes whenever the fiscal period was
closed/locked, but Verkstall bokslut closes the period before the
arsredovisning text is ever written, so every legitimate save failed with
PERIOD_LOCKED and the PDF fell back to placeholder text.

The narrative is arsredovisning document text (ARL 6 kap.), not journal
rakenskapsinformation, so the bookkeeping period lock does not apply.
Saves are now refused only once a Bolagsverket submission for the period
is registrerad (ARSREDOVISNING_REGISTERED, 409); the filed artifact was
already frozen separately by the submissions immutability trigger.

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

* fix(skatteverket): surface dead SKV connections and nudge reconnect

Prod has ~70 companies that connected Skatteverket before the post-connect
sync fix (#1010) and silently never synced skattekonto: the only reconnect
prompt lived in the settings panel nobody revisits.

- transactions-page banner when the connection is needs_reconsent or
  expired without refresh, linking to /settings/tax
- pre-connect note in the connect panel: approve ALL behorigheter on
  Skatteverket's consent page (previously only shown after a failure)
- wire the inert skattekonto.connection.expired event to an email nudge
  to the token owner; one send per consent episode via claim-first dedup
  in notification_log (type skv_connection_expired, partial unique index
  in migration 20260720090000, applied to staging)

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

* fix(archive): per-year behandlingshistorik covers late-booked vouchers + Drive backup disclaimer

The per-fiscal-year archive filtered audit rows by created_at within the
period, dropping treatment history for bokslut entries, stornos and SIE
imports booked after year end (BFNAR 2013:2 kap 8). The year archive now
unions the date window with every audit row touching the period's journal
entries and lines, deduped by audit id; line rows (company_id NULL by
trigger design) are admitted via a scoped OR and reachable on the
service-role backup path. ARCHIVE_FORMAT_VERSION 2->3 forces a one-time
Drive re-upload so existing archives pick up the complete history. The
Drive card on /import Exportera and the LASMIG texts now state the Drive
copy is a convenience backup, not the BFL 7 kap legal archive.

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

* fix(decisions): clarify Arsredovisning narrative save behavior on submission status

* feat(invoices): gate payment links behind invoice settings opt-in

The payment-link section (manual URL field + Stripe auto-create toggle)
was visible on every invoice and auto-created Stripe links on send for
any connected company. It is now opt-in per company:

- new company_settings.invoice_payment_links_enabled, default false for
  everyone (no grandfathering of Stripe-connected companies)
- invoice editor hides the whole section unless enabled; a draft that
  already carries a link still shows it so old links stay clearable
- enforced server-side in maybeCreatePaymentLinkForInvoice (after the
  provider lookup, so the extension-free core build never queries), so
  dashboard, v1, MCP and recurring sends all obey it
- new toggle on Settings -> Invoicing, saves instantly; sv/en strings

Migration applied to the staging branch; prod gets it on merge.

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

* fix(tests): add invoice_payment_links_enabled to company settings fixture

The makeCompanySettings fixture missed the new required boolean, failing
the core-only build's type check of tests/helpers.ts. Default false,
matching the migration default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(review): address CodeRabbit, compliance and Swedish review findings

Round 2 of PR #1076 review feedback, one change per accepted finding:

- pending page: res.json() safe fallback in both commit paths so a
  non-JSON proxy response cannot surface a raw parser error
- bulk-commit: map operation status enums to Swedish display labels in
  the 'Redan hanterad' skip message
- payment-link settings: disable the toggle while a save is in flight
  to prevent out-of-order PUT responses
- deadlines group card: route all UI strings through next-intl
  (deadlines namespace, sv + en)
- archive export: scope the period audit entry lookup to
  posted/reversed, matching the rest of the export
- error tests: assert the exact registry English message for
  ECONNREFUSED to lock the no-leakage contract
- signal routes: log.warn when best-effort lookups swallow a Supabase
  error (forensics), keep fail-closed behavior
- narrative route: document that 'avslutad' submissions deliberately
  stay editable (never registered at Bolagsverket)
- VAT: yearly declarations without an explicit fiscalPeriodId now
  resolve the räkenskapsår ending in the target year from
  fiscal_periods instead of assuming a calendar FY (SFL 26 kap
  10-11 §§); calendar fallback only when no fiscal period exists
- deadlines: IOSS deadline no longer requires vat_registered
  (Art. 369s has no Swedish VAT registration prerequisite)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:38:14 +02:00
Jakob Wennberg 88f53350de fix(errors): translate typed engine errors instead of leaking raw messages as journal_entry_error (#1048)
Typed bookkeeping Error instances passed to getErrorMessage() matched the
bare-envelope branch (any object with string code + message) and returned
their raw English message verbatim, so the categorize and match-invoice
routes surfaced strings like DB check-constraint violations directly in the
user's toast (issue #337).

- get-error-message.ts: when the bare-envelope shape is an Error instance,
  normalize it into the structured envelope ({ error: { code, message,
  account_numbers, details } }) so the existing per-code Swedish branches
  own the translation; plain forwarded envelopes keep the passthrough.
- get-error-message.ts: structured-path final fallback now prefers the
  registry's message_sv for known codes whose message is not Swedish, so
  typed codes without a dynamic branch (e.g. CANNOT_REVERSE_STORNO) cannot
  surface English either.
- categorize + match-invoice routes: always map the caught error through
  getErrorMessage (the raw error is already logged); untyped errors fall to
  the Swedish context fallback instead of leaking err.message.
- Tests: new instance-translation suite in lib/errors, typed-error case in
  the categorize route suite, and deliberate updates of the two tests that
  pinned raw 'Period locked' passthrough.

Fixes #337

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:51:07 +02:00
Jakob Wennberg f0907da7e9 fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches (#1033)
* fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches

The v1 (MCP-facing) match-supplier-invoice route resolved paymentAccount
via resolveSettlementAccount but only passed it to
createSupplierInvoicePaymentEntry for pure-SEK matches (gated on
isPureSek) and never to createSupplierInvoiceCashEntry at all. A
foreign-currency match, or a kontantmetoden match, settling from a
bank/cash account other than the primary 1930 (e.g. a EUR account on
1940) was still misbooked to 1930: the same class of bug PR #985/#986
fixed for the pure-SEK accrual path.

Pass the resolved account through both branches unconditionally (the
generators' internal 1930 default remains the documented no-link
fallback, reached via resolveSettlementAccount's own fallback for
transactions without a cash_account_id), and widen the
findUnresolvableAccounts chart pre-validation from the pure-SEK accrual
path to every non-customLines branch, since all of them now consume the
resolved account.

The dashboard route needed no code change: its FX/cash-method branches
were already threaded inside PR #985 itself. Added branch-level
regression tests on both routes (linked non-1930 account books to that
account; no cash_account_id falls back to 1930; deactivated resolved
account rejects with ACCOUNTS_NOT_IN_CHART before booking).

Fixes #1000

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

* fix(v1): validate settlement account against the chart before the conflicting-JE storno

The widened findUnresolvableAccounts pre-validation ran after the
conflicting-categorization storno, so a request rejected with
ACCOUNTS_NOT_IN_CHART could first reverse the transaction's posted
categorization entry: an irreversible side effect on a failed request.
Move the paymentAccount resolution and the chart validation ahead of
the storno block (same !customLines guard, same error shape) and add a
regression test asserting reverseEntry is never called when the chart
validation fails. The dashboard route has no storno block and no chart
pre-validation on this path, so it is unaffected.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:13:09 +02:00
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00
Jakob Wennberg f04dc4c4e0 fix(transactions): allow re-linking a bank tx stranded on a reversed verifikat (#988) (#1009)
* fix(transactions): allow re-linking a bank tx stranded on a reversed verifikat (#988)

A transaction whose journal_entry_id points at a reversed/cancelled entry reads
as "utan koppling" in the UI (the transactions page enriches only status='posted'
links), yet the re-booking guards treated ANY non-null pointer as "already
linked". So a storno'd/corrected transaction could never be linked to another
verifikat or re-categorized: the exact symptom in issue #988.

Add a shared hasLiveJournalEntryLink() predicate used by every re-booking guard
(linkTransactionToJournalEntry, manualLink, categorize-core, and the MCP link
stage-check): a pointer at a non-posted entry is treated as re-linkable, and the
two optimistic-locked writes now lock on the exact previous pointer (null OR the
stale id) instead of always .is(null), so the overwrite goes through race-safely.
hasLiveJournalEntryLink fails closed on a read error so a transient blip can't
detach a genuinely live link.

The source was fixed in #726 (reverseEntry/correctEntry now detach/re-point the
tx); this makes the guards self-heal for the pre-#726 backlog and any future
best-effort miss.

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

* fix(transactions): detect 0-row CAS before invoice effects; fix categorize commit test

Addresses PR review (CodeRabbit Critical + CI):

- link-journal-entry.ts: the tx UPDATE now .select('id') and treats a 0-row
  result as LINK_TX_TX_ALREADY_LINKED, failing BEFORE any invoice settlement /
  invoice_payments insert. Without this, a concurrent re-link that lost the CAS
  would still mark the invoice paid against a transaction we didn't link (same
  optimistic-lock contract manualLink already enforces).

- pending-operations commit route test: the categorize_transaction "already
  categorized" case now enqueues the hasLiveJournalEntryLink status read (posted
  = live) so it still returns 409. This was the core-only CI failure: the new
  liveness read in categorize-core consumed a queued response.

- Updated the link happy-path / invoice-race test enqueues to return a row for
  the now-selecting tx UPDATE.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:43:02 +02:00
Jonas Flodén 64ea0fef02 fix(transactions): resolve customer-invoice payment account from cash_account_id (#987)
* refactor(transactions): add shared settlement-account resolution helper

Cherry-picked from fork/worktree-starry-waddling-wirth (PR #985) commit
34d5d35 — pulling in just the new lib/bookkeeping/settlement-account.ts
helper and its test, without the match-supplier-invoice route changes
from that PR (those depend on 8bfc31d, not yet on main, and are out of
scope here).

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): resolve customer-invoice payment account from cash_account_id

Customer-invoice payment matching never resolved the bank leg from the
matched transaction's own cash_account_id: it was unconditionally
hardcoded to 1930 in buildInvoicePaymentClearingLines,
createInvoicePaymentJournalEntry, and createInvoiceCashEntry, with no
override parameter at all. Any bank receipt landing in a non-primary
cash/bank account (a secondary SEK account, or a foreign-currency
account like 1940 for EUR) was silently misbooked to 1930 -- the same
class of bug PR #985 fixed on the supplier-invoice side, except
unconditional there (no stale-setting trigger needed).

Adds an optional paymentAccount parameter (default '1930', preserving
behavior for every caller that doesn't pass one) to the three lib
functions, and threads resolveSettlementAccount(cash_account_id) through
every real bank-transaction-matching call site: the dashboard
match-invoice route (POST + preview), its v1/MCP-facing counterpart, and
the agent/MCP match_transaction_invoice commit path. Deliberately left
on default 1930: mark-paid (dashboard + v1, no bank transaction in
scope), fix-cash-mismatch (narrow historical repair tool for a different
bug), and the agent mark_invoice_paid commit path.

Brings in lib/bookkeeping/settlement-account.ts (cherry-picked from
fork/worktree-starry-waddling-wirth commit 34d5d35) so this PR is
mergeable independently of #985's merge order.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-entries): cover ROT/RUT 1513 line stays fixed under a non-default paymentAccount

Compliance-bot finding on PR #987: createInvoiceCashEntry's paymentAccount
override was only tested against a plain standard_25 invoice, never
combined with a ROT/RUT deduction_type item. The 1513 receivable line was
already correctly untouched by paymentAccount (it's never the bank leg),
this just closes the test-coverage gap.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors

Same shared-helper fix as PR #985/#986: resolveSettlementAccount now
throws BookkeepingDatabaseError on a genuine cash_accounts query error
instead of warning and falling back to 1930. An explicit cash_account_id
almost certainly resolves to a non-1930 account, so a transient failure
masking it risked the same class of misbooking this whole PR series
exists to fix, just via infra flakiness instead of a stale setting.

No route/commit.ts changes needed: match-invoice (POST + preview) run
under withRouteContext's existing catch-all, and commitPendingOperation
already has identical generic bookkeeping-error handling for every other
engine failure. Added regression tests for all three call sites
(dashboard POST, preview, and the agent/MCP commit path) confirming the
abort rather than assuming the shared infrastructure handles it silently.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(v1): guard resolved settlement account against chart of accounts

Closes the two remaining gaps from jakobwennberg's triage on #987
(after rebasing onto main and picking up the already-pushed
resolveSettlementAccount abort-on-error fix):

- Added the v1 match-invoice route-level test coverage that was
  missing (cash-account threading, BOOKKEEPING_DATABASE_ERROR abort,
  ACCOUNTS_NOT_IN_CHART), mirroring the dashboard route's existing
  settlement-account-resolution tests.
- Added the same findUnresolvableAccounts pre-validation guard against
  chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice
  route, gated on !customLines since that is the only branch here that
  consumes the resolved paymentAccount.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(bookkeeping): align settlement-account error assertion with #985

Use .rejects.toBeInstanceOf(BookkeepingDatabaseError) instead of
toMatchObject({ constructor: ... }), matching #985's edef79d follow-up
(the assertion was correct either way, but this is the more idiomatic
check and now makes the shared helper's test file byte-identical
across #985/#986/#987, removing the add/add merge conflict between
them noted in the merge-order validation.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-payment-lines): add missing 3740 coverage for non-1930 paymentAccount

CodeRabbit nitpick on #987: the test named "...does not affect the
FX-diff or öresavrundning lines" only exercised the 3960 FX-diff
branch, never the pure-SEK 3740 öresavrundning branch it also claimed
to cover. Split into two tests: the existing one renamed to describe
only its FX-diff coverage, plus a new pure-SEK sub-krona-short case
with a resolved non-1930 paymentAccount asserting the 3740 line books
correctly and the bank leg lands on the resolved account, not 1930.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(ci): quote compliance-pr.yml name to fix invalid YAML

The unquoted colon in `name: compliance: review (advisory)` (introduced
by #890's em-dash removal, which swapped an em dash for a colon
in-place) makes YAML read it as a nested mapping key, so GitHub can't
parse the workflow at all - every run fails with 0 jobs scheduled.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* Revert "fix(ci): quote compliance-pr.yml name to fix invalid YAML"

This reverts commit e7c890245d1834cd8f3c9b13a2bc3247fea7eacb.

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-12 21:23:19 +02:00
Jonas Flodén 528c53ffe7 fix(transactions): stop defaulting supplier-invoice payment account to a stale private-funds setting (#985)
* fix(transactions): stop defaulting supplier-invoice payment account to a stale private-funds setting

match-supplier-invoice (POST + preview) defaulted the credited cash account
from company_settings.last_supplier_payment_account, a sticky setting written
by the manual mark-paid "betald med privata medel" flow. Once that setting
held 2893 (skuld till aktieägare) from an unrelated private payment, every
later match against a real bank transaction reused it instead of the
transaction's actual bank account, silently booking genuine bank payments as
shareholder-loan repayments.

Resolve the credit account from the matched transaction's own
cash_account_id -> cash_accounts.ledger_account instead (falling back to 1930
when unlinked), mirroring the existing settlement-account lookup in
transactions/[id]/categorize/route.ts. last_supplier_payment_account is no
longer read by either route.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* refactor(transactions): extract shared settlement-account resolution helper

Dedupe the identical cash_account_id -> ledger_account lookup across
match-supplier-invoice (POST + preview) and categorize into
resolveSettlementAccount, per CodeRabbit's nitpick on PR #985. Pure
extraction, no behavior change.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(transactions): cover settlement-account lookup-error and preview parity gaps

Adds the two test cases CodeRabbit flagged as missing on PR #985:
- POST match-supplier-invoice: cash_accounts lookup errors, falls back to
  1930 and warns (previously unexercised).
- preview match-supplier-invoice: linked cash account other than 1930
  (parity with the equivalent POST-route test).

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): thread resolved settlement account into FX/cash-method supplier-payment branches

Closes the remaining items from the Swedish-accounting-compliance bot
review on PR #985:
- match-supplier-invoice/route.ts computed paymentAccount via
  resolveSettlementAccount but only passed it into the pure-SEK clearing
  branch; the FX branch (createSupplierInvoicePaymentEntry) and
  cash-method branch (createSupplierInvoiceCashEntry) still defaulted to
  1930 internally even though both already accepted the parameter.
- resolveSettlementAccount now also warns (and falls back to 1930) when
  cash_account_id resolves to a row with no ledger_account, not just on
  a hard query error.
- Documents company_settings.last_supplier_payment_account's scope via
  a column comment: it must never be read to resolve a matched
  transaction's settlement account.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors

Compliance-bot finding on PR #987 (applies equally to #985/#986, shared
helper): resolveSettlementAccount treated "no cash_account_id" and "lookup
threw a real DB error" the same way -- warn and fall back to 1930. An
explicit cash_account_id almost certainly resolves to a non-1930 account,
so a transient failure masking it risked the exact class of misbooking
this whole PR series exists to fix, just triggered by infra flakiness
instead of a stale setting.

Now throws BookkeepingDatabaseError on a genuine query error; every
caller already runs under withRouteContext/withApiV1 (or the pending-
operations dispatcher), whose existing catch-all already converts any
isBookkeepingError() throw into the correct structured 500 -- no caller
changes needed. The "row found but ledger_account empty" case stays
warn+fallback (data-integrity gap, not a query failure).

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(bookkeeping): use rejects.toBeInstanceOf for settlement-account error assertion

Addresses CodeRabbit nitpick from the 2026-07-12 review round: matching
BookkeepingDatabaseError via a `constructor` key in toMatchObject is
non-idiomatic; toBeInstanceOf is the standard vitest assertion for this.

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* docs: scope FX/cash-method paymentAccount gap note to /api/v1 and MCP routes

CodeRabbit flagged the #1000 reference on PR #985 as ambiguous — the main
match-supplier-invoice route's FX/cash-method branches already thread
paymentAccount (per the prior entry), so the still-open gap only applies
to the /api/v1 and MCP-facing route.

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-12 21:04:39 +02:00
Jakob Wennberg 650c7be5e1 fix(bookkeeping): revive counterparty template learning (dead since the multi-tenant refactor) (#989)
* fix(bookkeeping): revive counterparty template learning, dead since the multi-tenant refactor (#865)

The learning half of counterparty templates has written nothing since
2026-03-30 (prod: 750 SIE imports, zero new templates). Two stacked bugs:

- The multi-tenant refactor re-scoped categorization_templates to
  company_id and the lib stopped writing user_id, but user_id kept its
  NOT NULL: every insert failed with a null violation that supabase-js
  returns rather than throws, so nothing was ever logged. Migration
  20260711100000 drops the NOT NULL and the dead user_id indexes.
- Four of six learning call sites (both categorize routes,
  categorize-core, the MCP server) passed the auth user id as companyId,
  so even with the column fixed the writes would fail FK/RLS and
  corrections could never find the template they were correcting.

Hardening while in here:

- insertOrUpdateTemplate now checks every write result, logs failures,
  and returns whether a row was written; populateTemplatesFromSieVouchers
  reports only templates actually persisted.
- Sign-mismatched matches (an incoming refund matching an expense-learned
  template) previously booked backwards: debit expense / credit bank for
  money coming IN. They are now mirrored into the correct refund shape
  (VAT leg reversed for deductible input VAT), flagged requires_review,
  and excluded from template/rule learning so a refund can never flip a
  learned template.
- Template amounts are computed from the SEK-resolved amount, so
  foreign-currency transactions no longer produce unbalanced multi-line
  entries (or VAT computed on foreign units).
- SIE extraction no longer hardcodes 25% for 2641 (rate-agnostic in BAS):
  the rate is inferred from voucher amounts and snapped to 25/12/6%, and
  reverse-charge counterparties learn vat_treatment='reverse_charge'
  instead of losing the RC legs (which also no longer poison the ratio
  base).
- New pg-real test locks the exact insert column set against the real
  schema, so a schema/code drift like this can't ship green again.

Closes #865

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

* fix(bookkeeping): mirror fiktiv-moms legs on RC credit notes, exclude import VAT accounts from ratio base

Compliance-review follow-ups on #989:

- REVERSE_CHARGE_VAT_ACCOUNTS gains the import output-VAT accounts
  (2615/2625/2635), which pair with 2645 in import vouchers exactly like
  the RC pairs and must not shrink the business ratio base.
- A sign-mismatched match against a reverse_charge template (an RC
  supplier's credit note) now mirrors both fiktiv legs (credit 2645 /
  debit 2614) instead of booking gross, so Ruta 30/48 net back to zero.
  The income line-builder nets VAT credits against debit legs to keep
  the mirrored pair balance-neutral (identical result for all existing
  credit-only output-VAT paths).

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

* fix(types): CategorizationTemplate.user_id is nullable since 20260711100000 (CodeRabbit)

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

* fix(bookkeeping): use roundOre for the VAT netting, keep the ore-round ratchet at baseline

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

* fix(bookkeeping): review-gate stale 12% templates across the livsmedel transition, pattern-aware direction guard

Compliance-review round 2 on #989:

- Livsmedel VAT dropped 12% -> 6% on 2026-04-01 (Prop. 2025/26:55) while
  restaurang/hotell stay at 12%. A reduced_12 template whose
  last_seen_date predates the transition can no longer be trusted
  unreviewed: its match is flagged requires_review until a
  post-transition approval refreshes it (re-approval keeps 12%, a
  correction relearns 6%). Actively-confirmed 12% counterparties flow
  without friction.
- The opposite-direction correction guard now falls back to the line
  pattern's business sides when the legacy fields are both
  settlement-ish and cannot classify a multi-line template.
- Documented the accepted import-RC mirroring limitation (2614 vs 2615
  ruta attribution) and the netted-vatCredit precondition.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:13:50 +02:00
Mattsson abe9ac9d8c Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

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

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

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

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

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

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

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

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

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

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

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

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

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

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

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

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

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

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:54:46 +02:00
Mattsson 2c2743eb79 Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

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

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

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

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

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

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

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

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

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 764348e99c feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)
* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

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

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

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

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:50:28 +02:00
Jakob Wennberg 678f2ccffd feat(mcp): P2 hygiene — honest category suggestions, skill-reference lint, cadence copy (#882)
* feat(mcp): counterparty-tied category suggestions + no_signal (P2-1)

suggest_categories padded every transaction with a company-wide
category-frequency fallback at <=0.5 confidence — an identical four-way
spread on 20+/24 items that agents correctly reported as pure noise
(agent.feedback). Real signal came from memory atoms and query_journal.

- History is now counterparty-keyed: buildMerchantHistory groups past
  categorized transactions by normalized merchant; the engine only
  surfaces history for THIS transaction's merchant, with provenance
  ('Bokförd N gånger tidigare för denna motpart') and occurrence-scaled
  confidence (0.56 at 1x, capped 0.85). No global padding — an empty
  list is the honest answer.
- The MCP tool returns no_signal_transaction_ids for transactions where
  NO source matched, steering agents to investigate (query_journal)
  instead of pattern-matching on unrelated rows.
- Both callers (REST suggest-categories route + MCP tool) share the new
  helpers, so web UI and agents improve together.

Part of dev_docs/mcp_optimization_plan.md (P2-1).

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

* feat(skills): dangling-reference validation in skills:check + fix 10 dangling links (P2-2)

skills:generate/check now fail when an atom SKILL.md links a
references/*.md that does not exist on disk — a dangling pointer ships
a 404 to every agent that follows it (the weekly-booking-check
incident, agent.feedback).

The validator immediately caught 10 live dangling links in 4 atoms,
three distinct flavors:
- filename typo: swedish-asset-accounting/references/depreciaton.md
  renamed to depreciation.md (the link was right, the file misspelled)
- link mismatch: swedish-e-invoicing linked market-providers-pricing.md;
  the file is market-provider-pricing.md (link fixed)
- unauthored plans: single-shareholder-ab-fmb TODOs and reklambyra's
  'planerad utbyggnad' section used resolvable references/ paths for
  files that were never written — rephrased as plans without paths

Seed migration regenerated (4 atoms bumped, renamed reference child).

Part of dev_docs/mcp_optimization_plan.md (P2-2).

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

* docs(events): align agent-feedback review cadence copy (P2-4)

gnubok_feedback replies 'we aggregate signal weekly'; the event-log
handler comment said quarterly. One of them was lying — weekly wins
(the mcp_optimization_plan triage is the living example).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:48:29 +02:00
Jakob Wennberg 755e0f7e47 feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)
* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

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

* test: use roundOre in new dims rounding assertions (ratchet)

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

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:01 +02:00
Jonas Flodén 1cd8863958 fix(transactions): resolve bank account from cash_account_id in booking dialog (#769)
* feat(transactions): expose cash_account_id in list API response

Add cash_account_id to the transactions list API select so that components
can resolve the bank account from the transaction instead of hardcoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* feat(cash-accounts): extract resolveAccount to shared utility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* refactor(transactions): use shared resolveAccount in MatchVoucherDialog

Replace the local resolveAccount function with the shared utility from
lib/cash-accounts/resolve-account, reducing code duplication and improving
maintainability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): resolve bank account from cash_account_id in booking dialog

Replaces the hardcoded '1930' bank leg in TransactionBookingDialog with
the actual ledger_account of the transaction's cash account. Companies
with multiple bank accounts (e.g. 1930 + 1940) now get the correct
account pre-filled in both the blank and template-based booking flows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): cancel stale cash-account fetch on dialog re-open

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): prevent form remount discarding edits during bank account fetch

Hold JournalEntryForm render until the /api/cash-accounts fetch resolves by
changing bankAccount state to string | null (null = pending). This prevents the
form from mounting with key '…-1930', then immediately remounting with the
correct account key and losing any user edits made in the sub-100ms window.
Also adds r.ok guard before parsing and sets '1930' as explicit catch fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): cancel stale cash-account fetch in MatchVoucherDialog

Pass a signal object into loadCandidates and return a cleanup from the useEffect
so a stale in-flight fetch (from a previous transaction) cannot call
setAccountNumber/setAccountFallback/setGlLines/setSelected after the dialog
re-opens for a different transaction. Also adds r.ok check before parsing
/api/cash-accounts response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
2026-06-29 23:08:04 +02:00
Jonas Flodén da692c2898 fix(transactions): include 'overdue' in match-supplier-invoice CAS guard (#779)
SupplierInvoicePicker shows overdue invoices as payable candidates, but
the CAS update in the match route omitted 'overdue' from its status
whitelist. This caused the update to return 0 rows for any overdue
invoice, committing a journal entry and then orphaning it before
returning MATCH_SI_NOT_OPEN — making the match appear to fail due to a
concurrent request. The v1 route already had this correct.

Signed-off-by: Jonas Flodén <jonas@floden.nu>
2026-06-29 22:13:48 +02:00
Mattsson 9ed0b9515a Fix/invoice booking vat fixes (#778)
* feat(invoices): add Plusgiro input to bank details settings

Plusgiro was already persisted, validated by the API schema, rendered on
the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI
had no field to enter the number, so plusgiro-only users could not fill
it in. Add the input next to Bankgiro with Luhn validation and hyphen
formatting, include it in the save payload (normalised on save so raw
digits still match the dashed schema format), and add sv/en strings.

Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests.

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

* fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips

Two user-reported bugs:

- PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and
  fell back to the customer-driven 25% rate, so a non-momsregistrerad seller
  saw VAT in the review step even though the created invoice books none. Mirror
  the server-side write gate (build-invoice-write.ts): force 0% when
  vat_registered is false (delivery notes excepted).

- InfoTooltip rendered TooltipContent without a Portal, so tooltips were
  clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice
  journal-entry review. Wrap in TooltipPrimitive.Portal.

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

* fix(transactions): book library mall from its literal lines, not a lossy fallback

Booking a bank transaction with a user-created booking-template (mall) via the
convertible "QuickReview" fast path reduced the template to a single category +
one account_override, silently discarding the chosen debit/credit. A
kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930),
or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the
direction inferred from the business/settlement line tags, so visually-identical
templates produced different verifikationer.

Route every library template through the journal-entry editor (applyTemplate ->
/book), which posts the literal lines, regardless of convertibility. Add
regression tests locking the contract.

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

* fix(bookkeeping): make the booking-time duplicate guard bypassable

TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but
the UI dead-ended on a toast with no way to do so. Add a shared
DuplicateBookingDialog that surfaces the already-booked sibling and lets the
user review it or book anyway (force bound to the reviewed candidate, which
the server re-detects so a stale id cannot wave the guard away).

- Wire the dialog into the /transactions categorize flow and the manual
  booking dialog (JournalEntryForm -> /api/transactions/[id]/book)
- Bind the override to expected_duplicate_transaction_id OR
  expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice,
  salary run) can be confirmed too
- Extend the guard to the pending-operations commit path and the MCP server
- Tests for book/categorize routes, detection, and the commit guard

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

* fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path

The web /book and /categorize routes append a durable
BankTransactionDuplicateDismissed event when a user books over a detected
possible double-booking. The agent commit path (commitCategorizeTransaction,
commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true,
leaving no behandlingshistorik — an auditor could not reconstruct why the
duplicate was allowed (BFNAR 2013:2 kap 8).

When allow_duplicate=true, re-detect the candidate and append the dismissal
event (BankTransactionDuplicateDismissed for the bank-line path,
InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging
failure never blocks a legitimate booking. Payloads stay PII-safe (ids,
amounts, dates only — no customer or merchant name).

Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds
expected_duplicate_journal_entry_id, not candidate.transaction_id, so the
systemdokumentation matches the actual control (BFL 7 kap).

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

* test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests

The gnubok_categorize_transaction tool runs the booking-time duplicate guard
before staging; its detection queries consumed the queued supabase mock
results, so the staging assertions saw a thrown duplicate error instead of a
staged op. Mock detectBookingDuplicate to "no duplicate" since these tests
don't exercise that path.

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

* refactor(transactions): use roundOre for duplicate-guard öre rounding

Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the
booking-time duplicate guard (detection lib, commit executor, MCP categorize
tool), satisfying the no-new-antipatterns ratchet guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:35 +02:00
Mattsson 8322830f46 Add/issue in absurdum (#739)
* feat(assets): allow editing fixed asset fields before depreciation

The fixed asset register only offered a "Dispose" action, so correcting a
mis-entered acquisition date/cost/category meant running the disposal flow —
which posts a real divestment voucher plus a Ch. 8a VAT adjustment.
Disproportionate and wrong for a data-entry fix.

Add an Edit action that allows correcting those fields directly, gated for
correctness:

- service: extend updateAsset() with category/acquisition_date/
  acquisition_cost; block the change once the asset is disposed or has posted
  depreciation (AssetCorrectionBlockedError) where it would desync posted
  vouchers from the register; realign the BAS triple on category change.
  Name, useful life, and method stay editable.
- api: extend the PATCH schema; annotate GET /api/assets with
  has_posted_depreciation so the UI can lock basis fields proactively.
- ui: EditAssetDialog + pencil action; disables date/cost/category when
  depreciation has been booked, with an inline explanation.
- errors: register ASSET_CORRECTION_BLOCKED (409).
- tests: unit tests for the guard; pg test for pre-disposal editability.

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

* feat(assets): also block basis edits when depreciation was hand-posted

The correction guard only consulted depreciation_schedules, so an
avskrivning booked as a manual journal entry (no schedule row) slipped
through and a basis correction was wrongly allowed.

Add a ledger scan: any posted credit to the asset's ackumulerade-
avskrivningar account (12x9) counts as depreciation. Entries that
depreciation_schedules attributes to a *different* asset are excluded, so
a sibling's engine avskrivning on a shared 12x9 account doesn't produce a
false block. What remains is depreciation tied to this asset (engine or
manual); a basis correction is blocked there and must go through storno.

Adds two unit tests: blocks on a hand-posted credit, allows when the only
12x9 credit belongs to a sibling's engine entry.

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

* fix(invoices): allow negative unit prices for discount lines

The invoice creation form rejected negative unit prices via a frontend
superRefine check, blocking valid discount lines (e.g. "Rabatt -100").
The unit_price error was never rendered inline, so submission failed
silently. The backend schema already allows negative unit prices (see
CreateInvoiceItemSchema test), so the form was simply out of sync.

Remove the non-negative constraint; empty/NaN prices are still rejected
by the base z.number() type. Drop the now-unused validation_price_positive
translation key from both locale files.

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

* feat(invoices): allow editing draft invoices

Drafts could be saved but not edited — the only way to change a draft's
lines, customer, dates or amounts was to delete and recreate it. Add a
"Redigera" action on draft invoices that opens the invoice editor
pre-filled with the draft and saves changes in place.

A verifikat is only created when an invoice is sent (or paid, under
kontantmetoden), so every status=draft invoice is uncommitted and safe to
edit; sent/paid invoices stay immutable and still require a credit note.

- Extract buildInvoiceWriteData() with the shared validation + computation
  (VAT rules, ROT/RUT, accruals, totals, currency, item rows); POST now
  uses it too, behaviour unchanged.
- Add UpdateInvoiceSchema and PATCH /api/invoices/[id], guarded to drafts
  (status=draft, no journal entry, not self-billed); number and status are
  preserved and no invoice.created is emitted.
- Extract the invoice creator into a shared InvoiceEditor with create /
  edit modes; /invoices/new is now a thin wrapper and /invoices/[id]/edit
  is the new edit page.
- Add a "Redigera" button on draft invoice detail pages + sv/en strings.
- Tests for the builder, UpdateInvoiceSchema and the PATCH route.

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

* feat(reports): make Huvudbok findable via account/saldo search terms

Searching the command palette for natural phrases like 'saldo per konto', 'kontoutdrag', 'kontoanalys' or 'transaktioner per konto' returned nothing, so users couldn't find the general ledger. Enrich the Huvudbok entry's keywords with those synonyms, and let Saldobalans and Balansrapport match 'saldo per konto' too since they are genuinely per-account balance views.

Companion change — the clearer Huvudbok report description ('Saldo och alla transaktioner per konto') — already landed in d5f474cb.

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

* feat(settings): let users edit their personal name

Add an editable Namn field to /settings/account that updates profiles.full_name and best-effort syncs auth user_metadata. Previously the personal name was only ever set from BankID's legal name at signup with no way to correct it, so users whose tilltalsnamn isn't their first given name were greeted by the wrong name (and email/password users had no name at all).

New POST /api/user/profile route (requireAuth, RLS-scoped update) mirrors /api/user/locale. sv/en strings added.

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

* feat(invoices): per-invoice öresavrundning override

Add a display-only öresavrundning flag per invoice that wins over the
company-wide setting. Resolution order in getDisplayTotal: per-invoice
override -> company setting -> default-on. The stored total and the booked
verifikat keep the exact öre; only the rendered total changes.

Supplier invoices gain the same flag but resolve a null to off (they never
had rounding historically), exposed via a toggle on the new-invoice form
and a rounding row on the detail page.

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

* feat(transactions): warn on possible duplicate before booking

Before committing a transaction (via book or categorize), detect an
already-booked sibling with the same date and amount and return a 409
TRANSACTION_BOOK_POSSIBLE_DUPLICATE instead of silently double-booking.

The user can override with force=true, which must be bound to the reviewed
sibling via expected_duplicate_transaction_id; the candidate is re-detected
server-side, so a stale or guessed id is rejected with
TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH. Detection is fail-open on the
non-force path and fail-closed under force.

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

* feat(transactions): shadow-mode scope-drift dedup counter in bank ingest

Count rows that an enforcing same-feed scope-drift rule WOULD treat as
re-imports (the IBAN-drift re-imports the external_id check misses) and
surface it as IngestResult.shadow_scope_drift_candidates. Nothing is
blocked yet -- the counter only measures how often the rule would fire so
it can be validated against real data before enforcement.

Also gitignore scripts/delete-duplicate-transactions.ts: a destructive,
hand-run cleanup tool kept out of the repo so it can't run in CI/cron or be
mistaken for a supported feature.

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

* fix(bokslut): base bolagsskatt on post-disposition result

Bokslutsdispositioner are booked as source_type='year_end', which the
income statement excludes, so net_result alone overstates resultat före
skatt and the booked tax ignored the periodiseringsfond avsättning (too-high
tax, ÅR/INK2 mismatch).

calculateBolagsskatt now accepts resultBeforeTaxOverride. The preview builder
mirrors each proposal's P&L effect (+återföring, -avsättning, -SLP) onto the
pre-disposition result; the commit path sums the already-posted dispositions
via the new sumPostedYearEndDispositions (class 88 + 7533) since bolagsskatt
is committed last.

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

* feat(settings): fiscal years manager

Add a FiscalYearsManager to the bookkeeping settings that lists fiscal
periods with their status (closed > locked > open) and creates the next
year via CreatePeriodDialog, seeded to chain forward from the latest
period end.

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

* fix(api): return 400 when locking a period with unbooked transactions

lockPeriod() refuses to lock a period that still has uncategorized business
transactions. Detect that message in the lock route and surface it as a
clear PERIOD_HAS_UNBOOKED_TRANSACTIONS (400) instead of a generic 500.

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

* feat(invoices): implement isEditableInvoiceDraft utility and apply it across invoice edit routes
feat(transactions): log duplicate dismissal events in behandlingshistorik
test(invoices): add tests for isEditableInvoiceDraft function
test(transactions): enhance tests to verify behandlingshistorik logging
refactor(bokslut): update tax calculation test descriptions for clarity

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:42:37 +02:00
Jakob Wennberg 88f49c0ccc fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726)
Bundles a set of bookkeeping-correctness fixes developed together.

Correction / storno flow
- correctEntry resolves (and seeds standard BAS) accounts for the
  corrected lines BEFORE writing the storno. The old order created and
  posted the storno first, then hit AccountsNotInChartError on the
  corrected lines and had to cancel it again — leaving a voided 0 kr
  storno in the chain and permanently burning a voucher number (an
  unexplained BFNAR 2013:2 gap). It now fails fast with nothing written.
- correctEntry re-points the bank transaction and underlag from the
  reversed original to the live corrected entry, so the transaction keeps
  reading as booked (and stays correctable) and the underlag travels with
  it. recordateEntry delegates both relinks to correctEntry.
- reverseEntry (engine) clears transactions.journal_entry_id for rows
  booked by the reversed entry, so a plain storno returns the bank row to
  "Att bokföra" with a re-booking affordance. The agent paths did this
  manually; the dashboard reverse route did not.
- findUnresolvableAccounts replaces findMissingActiveAccounts in the
  categorize routes: a standard BAS account merely absent from the chart
  is seeded on demand by the engine, so pre-validation must not 400 on it
  — only unknown numbers or deactivated accounts block.
- CorrectionChain dims cancelled (0 kr) entries and labels them so they
  no longer render like a live storno.

Report accuracy
- calculateVatLiability() (lib/reports/kpi.ts) is shared by the KPI route,
  the KPI xlsx export and the MCP period-summary tool, and uses the same
  26xx accounts as the momsdeklaration (ruta 49). Reverse-charge and
  import pairs (e.g. 2614 credit + 2645 debit) net to zero instead of
  inflating the receivable (#715). VAT_OUTPUT_ACCOUNTS / VAT_INPUT_ACCOUNTS
  are derived from ACCOUNT_RUTA so the widget can never drift from the
  declaration.
- Kassaflödesanalys records erhållna aktieägartillskott (2093) as a
  financing inflow and counts överkursfond (2086/2097) toward nyemission.
  2093 was previously unmapped, so any contribution broke the 19xx
  reconciliation by exactly the contributed amount (#716). Wired through
  the report type, both PDF templates, the K3 PDF, the dashboard client
  and the årsredovisning summary type.

Agent guidance
- shared-rules: describe the real Accounted correction flow (Rätta rader /
  Rätta datum / Radera verifikat, on-demand BAS backfill) so the assistant
  stops inventing flows that don't exist.
- verifikation-draft: clearer locked-period guidance.

Tests cover all of the above (storno fail-fast + seeding + relink,
reverseEntry unlink, findUnresolvableAccounts, VAT netting and the
cashflow reconciliation cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:44 +02:00
Jakob Wennberg 0521c385d2 feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712)
* feat(transactions): per-row underlag status + attach-document dialog

- New "Matcha mot underlag" dialog on /transactions (inbox pick or fresh
  upload), the tx→doc mirror of the Documents view's matcher
- Per-row Underlag/Underlag saknas badges on booked history rows, driven
  by computeJeUnderlagStatus — same posted-only, exemption-aware scope as
  the worklist count so badge and count never disagree
- attach-document route + commit dispatcher now propagate the doc onto
  the verifikation when the tx is already booked (BFL 5 kap 6 §), with a
  409 guard for docs consumed by a different verifikation, idempotent
  re-attach (no same-value rewrite under period lock), and an honest 409
  when the period-lock trigger blocks the propagation
- Booking-dialog doc links also pin the doc to the transaction row
  (first linked doc wins) via the link route's new transaction_id param

messages/{sv,en}.json also carries the strings for the pending-ops
expiry UI that lands in the next commit.

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

* feat(pending-operations): auto-expire stale staged operations after 30 days

- New daily cron (02:30 UTC, vercel.json + both docker crontabs) flips
  >30-day-old pending ops to rejected with the dispatcher's
  { auto_rejected: true, reason: 'expired' } result_data shape — rows are
  never deleted, the table is the audit trail
- /pending renders an "Utgick automatiskt" badge + detail line for these,
  orders terminal tabs by resolved_at so a fresh expiry sweep isn't
  buried, and adds a first-time-reviewer explainer
- Origin labels spell out where a proposal came from (AI chat, MCP key,
  API, cron) instead of the raw actor_label
- agent_chat actor type added to PendingOperationActorType/AuditLogEntry
  (DB CHECK already widened in 20260519090000) and to the agent filter
- ApprovalCard notes that ignoring a proposal is safe

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

* docs(mcp): surface the client telemetry marker in connect instructions

Tag the connector URLs shown in ApiKeysPanel, the connect-claude doc and
the gnubok-mcp README with ?client=<surface> (claude-connector /
claude-code) and GNUBOK_CLIENT=claude-desktop for the npm bridge.
Telemetry-only — the server already reads the param/header; this just
lets us measure which Claude surface connected.

The claude mcp add copy blocks quote the URL: an unquoted ? in the query
string trips zsh globbing ("no matches found").

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

* review: fix stale-closure badge flip + zod-validate link route body (PR #712)

- handleDocumentAttached read journal_entry_id off the render-time
  transactions snapshot; if the list changed while the attach dialog was
  open the optimistic badge flip was silently skipped. Read it off the
  dialog's own subject (attachDocTx) instead.
- POST /api/documents/[id]/link now validates the body against the new
  LinkDocumentSchema (uuid-strict, all four fields) instead of a bare
  presence check on journal_entry_id — same canonical VALIDATION_ERROR
  envelope. Test fixtures switched to real UUIDs accordingly.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:13:51 +02:00
Jakob Wennberg e978136210 fix(supplier-invoices): payment-match integrity — no more paid-without-voucher half-states (#711)
* fix(transactions): abort supplier-invoice match when payment voucher fails

The match route caught a payment-JE creation failure and proceeded anyway:
invoice marked paid with payment_journal_entry_id NULL, a payments row with
no voucher, and the bank line linked but unbooked. That half-state is
unrecoverable from the UI — mark-paid rejects 'paid' invoices and the match
route rejects already-linked transactions (the "user can re-book" comment
was wrong). The v1 route was already strict; this aligns the cookie route.

A failed voucher now fails the whole match before any state mutation, with
bookkeeping errors mapped to their structured codes and a new
MATCH_SI_JE_FAILED fallback.

Incident: Arcim 2026-06-11 — invoice 20250928 marked paid with no payment
voucher because account 3740 was missing from the chart.

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

* fix(transactions): bank-sync supplier-invoice match is a suggestion, not a hard link

A high-confidence (>=0.85, unambiguous) supplier-invoice hit at sync time
set transactions.supplier_invoice_id directly — without booking a payment
or touching the invoice. The half-link then BLOCKED the match route
(MATCH_SI_TX_ALREADY_LINKED), stranding the bank line with no path to a
payment voucher and the invoice stuck on 'registered'.

Sync now always writes potential_supplier_invoice_id; the hard link is
reserved for completed matches where the payment voucher is booked.
High-confidence hits still drain the matching pool and skip the mapping
engine.

Incident: Arcim 2026-06-11 — RosholmDell 18299 (29 890 kr) auto-linked at
sync, unmatchable afterwards.

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

* feat(bookkeeping): seed standard BAS accounts on demand in the engine

A minimal company chart routinely lacks accounts that legitimate engine
flows reach — 3740 (öres- och kronutjämning) the first time a Bankgiro
payment lands a sub-krona off the invoice, 6580 on a first legal invoice.
createDraftEntry threw AccountsNotInChartError and turned a standard
account into a dead end.

The engine now backfills missing accounts from BAS_REFERENCE (full
metadata incl. SRU code) before failing. Conservative by design: unknown
numbers still throw, and deactivated accounts are never resurrected —
deactivation is a deliberate user choice. Concurrent seeding (23505) counts
as success.

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

* fix(supplier-invoices): require explicit expense account, drop the 5010 seed

Every new line item (and every AI-prefilled line) was silently seeded with
account 5010 Lokalhyra. AI extraction deliberately never suggests accounts,
so any invoice saved without touching the field was misbooked as premises
rent — legally wrong verifikat that need rättelse to fix.

Lines now start with an empty account: the supplier's
default_expense_account fills empty rows when set, and submit blocks with a
clear toast until every row has an account.

Incident: Arcim 2026-06-11 — a legal-services invoice (should be 6580) and
a SaaS subscription (should be 5420) both posted to 5010.

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

* refactor(bookkeeping): clarify voucher description suffix to (ankomstnr N)

"(ankomst 2)" read as "arrived twice" / a duplicate marker; it is the
company-internal sequential arrival counter for supplier invoices.
"(ankomstnr 2)" says what the number is. Existing posted vouchers keep
their old description (immutable).

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

* fix(transactions): cancel orphaned payment voucher when match loses the CAS race

When the payment JE posts but the invoice CAS update matches 0 rows (a
concurrent request settled it first), both match routes returned
MATCH_SI_NOT_OPEN and left the voucher orphaned in the ledger. mark-paid
has always compensated for exactly this case; the compensation is now a
shared helper (cancelOrphanedPaymentEntry: cancel + voucher-gap
explanation per BFNAR 2013:2) used by all three routes.

Flagged by the compliance swarm and the Swedish compliance review on
PR #711 — the one finding both converged on.

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

* fix(bookkeeping): next_voucher_number user_id fallback for service-role contexts

Mirrors 20260421170500 (commit_journal_entry got this fix; its twin did
not). Under a service-role client auth.uid() is NULL and the
voucher_sequences upsert fails its user_id NOT NULL check before
ON CONFLICT can arbitrate — even when the sequence row exists. Every
non-interactive caller of the storno/correction path
(getNextVoucherNumber → correctEntry) was broken.

Fallback: companies.created_by (same source seed_chart_of_accounts uses).
Interactive flows still record auth.uid(); DO UPDATE never touches
user_id on existing rows. Also restores SET search_path = public, lost
when 20260330 recreated the function after the 20260304 hardening.

pg-real: new test exercises the RPC on the superuser connection
(auth.uid() IS NULL) and asserts sequential numbers + owner attribution.

Found live: the Arcim repair script booked payment vouchers fine
(commit_journal_entry) but failed on corrections (next_voucher_number).

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

* fix(bookkeeping): harden cancelOrphanedPaymentEntry — never throw, breadcrumb before mutating

Two hardenings from the PR #711 review round:
- Whole body wrapped in try/catch: the caller is returning the correct
  CAS-conflict response, so an unexpected client rejection must not
  replace it with a 500 (best-effort is now a hard guarantee).
- The gap-recovery data (series, number, period, explanation) is logged
  BEFORE the cancel: the cancel and gap insert are separate statements,
  and a crash between them would otherwise leave a cancelled voucher
  with no BFNAR 2013:2 gap explanation and no way to reconstruct it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:44:15 +02:00
Jakob Wennberg b38b3d0230 fix(bookkeeping): settle öre differences to 3740 and improve supplier-invoice matching (#699)
Whole-krona Bankgiro/Swish payments of öre-bearing invoices were stranded
as partially_paid forever (e.g. 11 231 paid on an 11 231,25 invoice left
0,25 kr open). Book the sub-krona residual to BAS 3740 (Öres- och
kronutjämning) and settle the invoice in full, on both the supplier- and
customer-invoice match flows.

New shared pure helpers buildSupplierPaymentClearingLines +
planSupplierPayment mirror the customer-side primitives; routing preview
and commit through the same builder also fixes two pre-existing
preview↔commit drifts (payment account + line descriptions). Öre
absorption is accrual-only — cash entries book the full invoice, so
absorbing there would hide a 1930 discrepancy.

Also improves supplier-invoice ↔ bank matching:
- Pass-3 date window now spans [invoice_date-5, due_date+5] instead of
  due_date ±5, so early payments auto-match; an ambiguity guard demotes
  non-unique amount matches to suggestions.
- New retroactive matcher (on supplier_invoice.registered/.approved)
  surfaces the settling bank payment when the invoice is registered after
  the payment was imported. Matches are written as suggestions for
  one-click confirm-to-book, never silently auto-booked.

Tests: new unit tests for both pure helpers; extended matching, handler,
customer öre, and route suites. Full suite green (407 files / 5364 tests).

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