Commit Graph

1394 Commits

Author SHA1 Message Date
Jakob Wennberg 24614c19ff fix(cash-accounts): rebind movable transactions when remapping a PSD2 ledger (#2023)
When a PSD2 remap collides with an overflow duplicate cash account (1931 next to a promoted 1930), the unbooked, unmatched, non-anchored transactions on the duplicate are rebound onto the promoted row before the duplicate is resolved, so booking stops proposing the dead ledger. Rows that are booked or anchored via transaction_voucher_links / invoice_payments / supplier_invoice_payments stay put and the duplicate is demoted (never deleted) while any remain, as before. Supersedes #1756.

Co-authored-by: Daniel Stenborg <daniel@stenborg.se>
2026-08-30 11:23:15 +02:00
Jakob Wennberg cc7050f6cb fix(pdf): keep the minus sign on losses in standard-font PDFs (#1982) (#1987)
Intl sv-SE formats negatives with U+2212, which the bundled react-pdf Helvetica/Courier fonts cannot render, so a loss printed as a profit. lib/pdf/number-text.ts pdfNumberText maps U+2212 to an ASCII hyphen and prints negative zero unsigned; routed through financial-statement, kassaflodesanalys, reskontra, momsdeklaration, payslip (fmt, the literal Preliminar skatt sign and the calculation formula text) and operational-report templates, with content-stream regression tests. The K2/K3 arsredovisning templates keep main's formatPdfKronor from #2013.

Closes #1982
2026-08-30 11:22:05 +02:00
Jakob Wennberg 4b7343d5ec fix(errors): keep the SQLSTATE when wrapping database errors (#2027)
isTransientFailure() checks the driver's error code first, and 57014
(statement timeout) is already in its transient set. But the wrapping idiom
across the codebase was `throw new Error(\`Database error: ${err.message}\`)`,
which keeps the prose and drops the code. A retryable timeout therefore
arrived anonymous and resolved to UNKNOWN_ERROR: "Något gick fel. Försök
igen." An agent cannot dispatch on that, so it retried.

On production over 60 days, with the two bot integrations excluded: 1024 real
agent failures, 645 of them UNKNOWN_ERROR across 60 actors and 57 companies.
82 retry streaks of three or more identical failures, 462 wasted repeat calls,
53.1% of all agent error calls sitting inside a streak.

The worst offender traces to one line in core. gnubok_query_journal failed 164
times at a p50 of 8110ms while every other failing tool sat between 1 and
315ms, and its path is fetchEntryLines -> fetchAllRows, where
lib/supabase/fetch-all.ts threw `new Error(error.message)`. That is the
highest-traffic strip point in the repo: 31 callers, every paginated read.
query_journal already had a correct TRANSIENT_ERROR branch offering "retry, or
narrow with date_from/date_to" which could never fire, because by the time it
looked, the code was gone.

fetch-all keeps the driver message verbatim: callers match on the existing
text, and this adds the code rather than rewording anything.

Attaching the code is safe. extractCode() only accepts /^[A-Z_]+$/ and every
SQLSTATE contains digits, so it cannot be mistaken for one of our own stable
codes. There is a test for that, and one asserting the old bare-Error shape
still resolves to UNKNOWN_ERROR so the fix cannot silently regress.

Also stops rendering the literal "undefined" when a driver-level failure
carries no message, which is the string that made these unsearchable.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:05 +02:00
Daniel Stenborg 7e76961da1 fix(inbox): preview underlag via the inline document proxy (#1753)
Dokumentinkorgen previews PDFs and images through the same-origin /api/documents/:id/inline proxy instead of the Supabase signed URL, which Chrome blocks in a frame (Content-Disposition: attachment). HTML underlag already used the proxy.

Co-authored-by: Daniel Stenborg <daniel@stenborg.se>
2026-08-29 10:05:32 +02:00
Jakob Wennberg 338ac4e913 fix(vat): make the ruta drill-down reconcile with the figure it explains (#2016)
* fix(vat): make the ruta drill-down reconcile with the figure it explains

get_vat_declaration_totals drops four classes of entry before summing: posted
closing entries, source_type 'vat_settlement', the two kontantmetod year-end
reversals, and anything shaped like a momsredovisning. The drill-down behind
each ruta filtered on company, status and date only.

So expanding a ruta listed verifikat that are not in the number it claims to
explain, and the panel shows no total that would reveal the mismatch. On
production, 322 posted/reversed entries carrying 26xx lines across 214
companies sit in those excluded classes.

A momsdeklaration is räkenskapsinformation under BFL 5 kap. and this
drill-down is what a consultant uses to substantiate a filed figure, so the
two have to agree exactly.

The exclusion CTEs are lifted verbatim from the figure rather than re-derived,
because any divergence reintroduces exactly this bug. The new pg test asserts
the equality for the whole account set at once, so editing one function and
not the other fails CI instead of silently misreporting.

opening_balance entries are deliberately kept: the figure exempts them from
its `shaped` set, which leaves their lines in the totals, so excluding them
here would break the equality in the other direction. That has its own test.

Verified the test catches the defect by reinstalling the old function body and
watching it fail with the real numbers (2611: drill-down 250/240 vs figure
0/200), then restoring.

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

* fix(vat): update the existing drill-down pg test to the new signature

get_vat_ruta_source_lines gained p_ruta_accounts / p_net_accounts, and
production-error-regressions.pg.test.ts still called the old 9-argument form,
so pg-real failed with 42883 "function does not exist". I had grepped app/,
lib/ and extensions/ for callers and not tests/.

Neither fixture in that paging test is settlement-shaped, so paging behaviour
is unchanged; the equality itself is covered by the new reconcile test.

Also documents, in the tool-pg reset script, that its blanket grant to `anon`
(which PostgREST requires) makes that database invalid for the pg-real suite:
~29 of those files assert least privilege and fail there even on unmodified
main. That cost a confusing local run.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:29:11 +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
Mattsson 1a8fe36bc4 fix(reconciliation): direction-aware NULL-link settlement for transfer legs (#2018)
* fix(reconciliation): direction-aware NULL-link settlement for transfer legs

A linked transaction with no resolvable cash account counted as settling
its voucher for every account. For an own-account transfer on a
non-primary bankavstamning card this hid the near leg while the far leg
stayed listed, producing a false oforklarat (reported: momskonto card
with differens 0 kr but oforklarat -2 593,75).

get_account_gl_lines_for_matching now discounts a NULL-attributed link
(pointer or junction) only when ALL of: the card is a non-primary
account, the voucher touches >= 2 of the company's cash-account ledgers,
and the row's sign contradicts the voucher's net leg on the account. All
other shapes keep byte-identical legacy behavior, protecting
unbackfilled single-leg rows (measured -37 000 kr false-alarm risk under
the naive primary-only rule, simulated per-card against prod before
choosing this rule; see DECISIONS.md).

Footprint measured on prod: 24 vouchers on 7 cards in 6 companies; 5
cards improve (3 to exactly 0,00), 2 surface a real user-fixable
mislink that was previously hidden.

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

* test(reconciliation): isolate the primary-card exemption in pg coverage

The existing primary-card assertion also passes via sign match; a reverse
transfer (1931 -> 1930) whose NULL row contradicts the primary's +net leg
pins condition 1 (legacy_null_ok) on its own. (CodeRabbit nitpick.)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 23:51:27 +02:00
Mattsson 7f0f25b558 feat(account): self-service login email change with double confirmation (#2017)
* feat(account): self-service login email change with double confirmation

New POST /api/account/email requests the change via the user session so
Supabase's AAL2 guard applies, and the account settings page gets an email
row with pending-confirmation state. Confirmation mails (both addresses)
and the /auth/callback email_change verification already existed; this
wires the missing initiation.

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

* feat(account): map email_exists to a 409 with Swedish copy

Changing to an address that already has an account is refused by GoTrue
(addresses are unique per auth user); surface that as a clear conflict
instead of the generic fallback.

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

* fix(account): trusted redirect origin + profiles.email sync trigger (skeptic findings)

- emailRedirectTo now derives from resolveRequestAppOrigin(): request.url
  can be an internal origin behind a proxy (dead confirmation links on
  self-hosted) and auth links must not follow attacker-chosen hosts;
  registered white-label hosts keep their brand.
- New migration 20260828191950: sync_profile_email trigger mirrors
  auth.users.email changes into profiles.email (member lists, notification
  recipients, AGI/KU contact, invite dedup all read profiles.email), plus a
  backfill for already-diverged rows. pg-real test included.
- Save button disabled while the same address awaits confirmation (no
  rate-limit re-fires); GoTrue's 'error sending email change email' now maps
  to the Swedish SMTP guidance.

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

* fix(account): idempotent repeat request for the pending address

CodeRabbit follow-up: a second POST for the address already awaiting
confirmation now returns the pending state without another GoTrue round
trip (no duplicate confirmation mails, no rate-limit burn). Claims-mapped
sessions lack new_email; GoTrue's send rate limit remains the backstop.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 22:12:20 +02:00
Mattsson a1cafe495f fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) (#2014)
* fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry)

PDF extraction read only part of well-structured PDFs, two confirmed
mechanisms (21-day prod window: 49 sliced docs, 29 silent empties):

- The auto-extract page budget was 3 (Bedrock-latency legacy, issue #553)
  and the slice kept only the first pages, so multi-page invoices lost the
  final page where totals, OCR and 'Att betala' sit. The budget is now 8 on
  pdf-native backends (Claude reads PDFs directly); the slice always keeps
  the last page. Rasterizing self-host backends keep the old budget of 3.
- A max_tokens-truncated model answer was parsed as-is, failed, and became
  an all-null extraction with no trace. extractFromDocument now reports
  stop_reason max_tokens / finish_reason length as truncated; the extractor
  retries once at double AI_EXTRACTION_MAX_TOKENS and logs
  ai_extraction_truncated either way.

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

* fix(invoice-inbox): sweep cutoff covers the slower two-call extraction

Skeptic finding on #2014: the crash-recovery sweep flipped 'processing'
rows to an empty skeleton after 2 minutes, but a deferred extraction can
now legitimately run 3-5 minutes (8 native pages plus one truncation
retry at a doubled token cap), so the sweep stole the row and the CAS
discarded the worker's real result. Cutoff raised to 10 minutes.

Also: pages_partial_note made period-agnostic (old rows were extracted
from first-pages-only slices, so naming the last page was retroactively
wrong for them), and two stale first-pages-only comments updated.

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

* fix(invoice-inbox): keep the first extraction response when the retry throws

CodeRabbit finding on #2014: a throttled/failed retry call bubbled to the
outer catch before rawText was assigned, discarding a first response whose
text may parse fine despite the truncation flag. The retry is now caught
locally (logged as ai_extraction_retry_failed) and the first result flows on.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:57:02 +02:00
Mattsson e8aa0670ca feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary

Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).

- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
  (draft gate, roundOre, 0 = nollkorning, display-line refresh); the
  cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
  budget at zero headroom), op type set_run_salary (medium risk),
  commitSetRunSalary executor, payroll:write scope, payroll_month
  loadout + payroll-monthly skill step; update_payslip_line description
  now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
  monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
  pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
  snapshot updated

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

* fix(salary): harden set_run_salary per skeptic + CI findings

- Clear calculation_breakdown when the per-run salary changes so the
  existing book preflights force a recalculation: a run can no longer
  be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
  v1 body schema: closes the unbounded/1e307-overflow path that wrote
  Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
  WRITE is uncallable on Claude.ai (update_customer lesson) while three
  surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
  with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
  committed; matches pre-refactor route behavior) and DB error details
  carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
  salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)

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

* chore(migrations): rename set_run_salary pair past main's newest versions

origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).

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

* chore: retrigger Supabase preview after migration-version repair

The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:14:31 +02:00
Jakob Wennberg a4ceaafa4f feat(inbox): per-item underlag anchoring status and a daily reconcile cron for stranded underlag (#1548) (#2012)
* feat(invoice-inbox): per-item underlag status and daily reconcile of stranded booked items (#1548)

The inbox derives "booked" from the matched transaction's verifikat, but
that says nothing about whether THIS item's document reached it: a link
that failed at propagation time, or a document anchored to another
verifikat, read as booked while the verifikat sat without its underlag
(BFL 5 kap 6-7 §). GET /items and /items/:id now also emit
underlag_status (anchored | unlinked | anchored_elsewhere) from one
batched document_attachments read; the workspace keeps divergent items
in "Att göra", drops the booking bridge for them (the book routes 409 on
a booked transaction) and shows one explanatory line with a link to the
verifikat.

The backfill script's loop moves into lib/transactions/
inbox-underlag-reconcile.ts and runs daily from a new extension-owned
cron (vercel.json plus the generated Docker crontabs): transient link
failures heal without an ad-hoc script run, permanent conflicts are
counted in one summary, and each repaired transaction leaves an
InboxUnderlagReconciled row in behandlingshistorik. That event type is
registered by migration 20260828154800: processing_history.event_type has
an FK to processing_event_types, and the script's previous
InboxUnderlagBackfilled type was never registered, so its appends had
always failed silently.

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

* fix(invoice-inbox): address review findings on the underlag reconcile (#1548)

Findings 1, 3, 6 (scan cap starves the tail): the reconcile no longer caps
the read. The matched-unconsumed candidate set holds permanent residents
(samlingsverifikat siblings, anchored-elsewhere items) that never leave
it, so a uuid-ordered read cap would revisit the same 1000 rows every
night and never reach a stranded item sorting past the cut. The scan now
pages through every candidate (four columns per row) and maxItems bounds
the WORK: at most that many unlinked (or unreadable) items are propagated
per run; already-anchored, anchored-elsewhere and locked items are counted
from the pre-state without a propagation or budget. Items past the budget
are counted as deferred and truncated is logged at warn level.

Findings 2, 5 (false "linked automatically" promise for locked periods):
resolveUnderlagAnchoring reads the fiscal period lock state of the
verifikat for every unlinked item and reports unlinked_locked when
is_closed or locked_at is set, the same pair enforce_period_lock_documents
checks. The reconciler counts it separately (unlinkedLocked), never
propagates it and never warns "still unlinked after re-run"; the rail
shows a message that says the period must be unlocked first.

Findings 4, 7 (absent anchoring read as booked): the list and detail
enrichment emit underlag_status 'unknown' when the helper could not read
the document row, and the workspace treats any status but 'anchored' as
divergent (stays in Att göra, no booking bridge, own message). classify()
counts a repair only when the pre-state was explicitly unlinked, so an
unreadable before-read never earns an InboxUnderlagReconciled event.

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

* fix(invoice-inbox): address round-2 review findings (#1548)

1. [minor] Round-1 fix dropped propagation for transactions whose inbox
   items already read anchored, so the pinned-document leg
   (transactions.document_id) was never repaired and settled items never
   received their created_journal_entry_id stamp, staying in the scan and
   inflating alreadyAnchored every night. reconcileCompany now propagates
   every stranded transaction that has an unlinked (budgeted) item or an
   anchored / document-less item, outside the maxItems budget: the helper
   is idempotent and the stamp shrinks its own population. Locked-only and
   anchored-elsewhere-only transactions stay skipped. Counting and the
   behandlingshistorik trail are unchanged (anchored items keep their
   pre-state verdict, no event). Tests updated and a new case pins the
   anchored-item plus document-less-item transaction: propagated, no
   after-read, no history. DECISIONS line amended.

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-28 17:45:10 +02:00
Jakob Wennberg ad8566f1ae feat(settings): per-company data-analysis opt-in gating the calibration corpus (#1346) (#2007)
* feat(settings): per-company opt-in for data analysis of bookkeeping outcomes (#1346)

Adds company_settings.data_analysis_opt_in (default false, no grandfathering)
and gates every path that reads bookkeeping outcomes across companies on it:
POST /api/agent/categorize/outcome stops writing calibration samples for
companies that have not opted in, and the backtest / calibration-fit scripts
filter to opted-in company ids. One helper (lib/company/data-analysis.ts)
is the single gate for future analysis paths. A toggle on Inställningar >
Företag states plainly what is analysed (proposed vs booked account, amount,
confidence; no free text, no personal data) in sv and en. The flag is UI-only
by design: consent is a human action, so it is absent from the v1 REST / MCP
settings pick lists.

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

* fix(settings): make data-analysis consent copy true for the backtest path (#1346)

Addresses adversarial review findings on PR #2007:

- Findings 1-3 (consent narrower than the gated processing): the flag also
  gates scripts/backtest-categorize.ts, which re-runs transaction
  descriptions, merchant names and matched underlag through the model. The
  sv/en toggle help and disclosure now state that explicitly as "evaluation
  runs" and no longer claim that free text or underlag are excluded. The
  migration header and COMMENT, the lib/company/data-analysis.ts docstring,
  the backtest script header and the DECISIONS line say the same. Kept the
  gate (un-gating would put the script back to reading every company with
  no consent at all). A test pins that both locales name those inputs and
  contain no "no free text / no underlag" denial.
- Finding 4 (member sees an active switch that RLS rejects): the toggle is
  now enabled only for owner/admin, matching the company_settings update
  policy; the disclosure says only administrators can change the choice.

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

* fix(scripts): address round-2 review findings (#1346)

1. [minor] Opted-in company filter was an unbounded PostgREST `in` list in
   the URL (scripts/fit-categorize-calibration.ts, scripts/backtest-categorize.ts).
   Both scripts now read the opted-in ids through a shared, paginated helper
   (listDataAnalysisOptedInCompanyIds, fetchAllRows so the pre-fetch no longer
   caps at 1000) and query per chunk of 100 ids (chunkCompanyIds). The fit
   script pages each chunk on the id PK; the backtest merges per-chunk
   results and re-cuts to the N most recent overall. Early exit on zero
   opt-ins is kept. Pinned with tests in lib/company/__tests__/data-analysis.test.ts.

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

* fix(scripts): coerce a null transaction description in the backtest (#1346)

The typed row from the chunked consent query made description nullable,
which TransactionForSelect does not accept; fall back to the original
description or an empty string, as the untyped row did implicitly before.

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-28 17:38:36 +02:00
Jakob Wennberg 33a58bec51 fix(webshop-orders): shared effective-rate helper and order-context refusal for the rate-0 slot (#1912) (#2008)
* fix(webshop): share rate classification and check rate-0 order context in bulk book (#1912)

The bulk revenue template's guard copied fetchDynamicVatAccounts'
effective-rate precedence (explicit momssats > treatment > class-3
number+name inference), so the two could drift. Both now call one
exported helper, resolveEffectiveVatRate, and a sibling
resolveRevenueVatBox resolves the momsdeklaration box for a revenue
account (treatment ruta first, then the static BAS map).

The rate-0 slot also ignored order context: a domestic 0% order could be
routed to an export account (ruta 36) and vice versa, misstating rutor
35-42 with no VAT amount to catch it. The sweep now refuses, per order,
a 0% bucket whose billing country contradicts the chosen account's box:
ruta 36 vs SE or an EU country, ruta 40 vs SE, ruta 35/38/39 vs SE or a
non-EU country. Unknown country (Shopify), domestic boxes (42/41/07) and
unclassified accounts are unchanged; the domestic-account + foreign-
country direction stays advisory in the dialog.

Item 1 of the issue (require a positive momsfri/export/EU classification
for the slot) is deferred: most such accounts are unconfigured today and
the strict rule needs a configure path first (DECISIONS.md).

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

* fix(webshop): address review findings (#1912)

- Finding 1: the rate-0 context guard keys on customer_country, which the
  WooCommerce sync stores from the billing address; the goods boxes 35/36/38
  follow the delivery destination, so a Swedish-billed order shipped outside
  the EU is a legitimate ruta 36 export the sweep refuses. Soften the
  WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH copy (sv/en) to say the check is
  based on the billing country and the account may still be right for the
  delivery address, and ask the user to confirm rather than change the
  account. Document the limitation in the route comment; storing shipping
  country in the sync is a follow-up. Test pins the new wording.

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-28 17:22:38 +02:00
Jakob Wennberg 22f0647d6c feat(bookkeeping): make inline rattelse discoverable on the verifikat page (#1554) (#2011)
* feat(bookkeeping): make inline rättelse discoverable on the verifikat page (#1554)

A user who wanted Fortnox-style "stryk rader" went looking on the
verifikat page and concluded the feature did not exist: since #1739 every
correction action sits behind an icon-only ⋯ menu, nothing says which
correction track applies when, and the struck-line marker showed only a
date.

- Promote "Stryk rader i verifikatet" to a visible outline button for a
  posted, non-structural entry whose period the period-status endpoint
  reports as open; the ⋯ item stays so the menu remains the complete list.
- Add the convention-7 "?" after the H1 with the two-sentence track rule:
  inline rättelse while the period is open and unlocked, storno once it is
  locked, closed or declared.
- The rattelse-log route now returns an additive actor_label resolved
  from profiles via the service client (same precedent as
  behandlingshistorik); struck rows read "Struken {date} av {actor}" and
  the Rättelsehistorik rows carry the actor beside the date.

No change to the RPCs, the log table, or which corrections are legal.

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

* fix(bookkeeping): address review findings on inline rättelse discoverability (#1554)

- Tie the un-awaited period-status fetch to the fetchData run that issued
  it (monotonic request ref), so an earlier response resolving last can no
  longer set periodStatus='open' for an entry in a locked period and promote
  the "Stryk rader" button the RPC would refuse.
- Align the "?" help copy with what the system enforces: storno is the only
  path once the period is locked or closed; a VAT-declared month is stated
  as a caveat (same wording as the StrikeLinesDialog explainer), not as a
  gate the product does not apply. Both sv and en.

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-28 17:15:25 +02:00
Jakob Wennberg ca93ef3fb6 fix(salary): surface employee-save failures and a typed 503 for the missing encryption key (#1996) (#2009)
* fix(salary): surface employee-save failures in the dialog and type the missing encryption key (#1996)

Pressing Spara in "Ny anställd" could fail without any feedback: a thrown
fetch or a non-JSON 5xx body escaped handleSubmit before setSaving(false)
ran, leaving the button stuck on "Sparar..." and the dialog silent. Even
when the toast did fire, the Radix modal aria-hides the root-layout
Toaster, so assistive tech (and the E2E driver that found this) heard
nothing, and the requestId support needs was never shown anywhere.

- NewEmployeeDialog: fetch + parse run in a never-throwing helper, saving
  is released in finally, the body is parsed with json().catch(() => null)
  so an HTML/plain-text error page still maps through the HTTP-status map,
  and the failure is rendered inline (role="alert" in the footer) with
  "Ärende-id: <requestId>" next to the single destructive toast.
- personnummer.ts: the production "key missing" throw now carries the
  registry code PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED, and the SALARY
  registry gains a 503 entry naming PERSONNUMMER_ENCRYPTION_KEY with a
  "contact support" message and a remediation hint. withRouteContext
  emits the typed envelope automatically instead of INTERNAL_ERROR 500,
  which read as transient and invited retries that can never succeed.
- Tests for the route (401, 400, 503 with requestId and no insert), the
  key guard, the registry entry, errorResponse dispatch on a coded Error,
  and getErrorMessage locale handling of the new envelope.

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

* fix(salary): address review findings (#1996)

- NewEmployeeDialog: fall back to the X-Request-Id response header when the
  body carries no error.requestId. The route hand-builds its 409 (duplicate
  personnummer) and generic insert-failure 500 bodies as flat strings, so the
  inline "Ärende-id" line was hidden for exactly the DB-failure class the
  issue names; withRouteContext sets the header on every response.
- Route tests pin that the 409 and 500 insert-error arms carry X-Request-Id.

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-28 16:57:54 +02:00
Mattsson 89ce837947 fix(arsredovisning): losses lost their minus sign in the PDF (#2013)
* fix(arsredovisning): losses lost their minus sign in the PDF

toLocaleString('sv-SE') formats negatives with U+2212 MINUS SIGN, which
react-pdf's built-in Helvetica (WinAnsi encoding) has no glyph for, so the
sign was silently dropped: a loss rendered as a profit on 'Arets resultat'
and 'Summa fritt eget kapital' while all totals stayed correct. Reported by
a user whose -4 684,24 kr loss displayed as +4 684 kr.

Add formatPdfKronor (absolute value + ASCII hyphen-minus) and route the K2
and K3 PDF templates and the PDF-bound note builders through it. Display
only; no ledger or iXBRL changes (the iXBRL writer already handles signs
via the sign attribute and its own presentational minus).

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

* fix(arsredovisning): pin PDF group separator to U+00A0 across ICU versions

Newer CLDR data groups sv-SE thousands with U+202F NARROW NO-BREAK SPACE,
which WinAnsi Helvetica also lacks: digits would silently run together the
same way the minus sign was dropped. Normalize the separator to U+00A0 in
formatPdfKronor so the rendering does not depend on the runtime's ICU.

Addresses the PR Agent reviewer-guide finding on #2013.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:55:41 +02:00
Jakob Wennberg b4b11c33ca fix(arcim-migration): require a completed SIE import for Fortnox too (#2000) (#2006)
* fix(arcim-migration): require a completed SIE import for Fortnox too (#2000)

POST /migrate refused entity import without a completed SIE import for
every provider except Fortnox, on the assumption that the wizard always
runs SIE-over-API first. The wizard lets the user uncheck
"Bokföringsdata (SIE)" while keeping customers, suppliers and invoices
checked, so a Fortnox company could end up with subledgers but no chart
of accounts and no opening balances: the incomplete bokföring the guard
exists to prevent.

The guard now applies to all providers as "a completed SIE import must
exist for the company", not "must be part of this run", so an
entities-only re-run after an earlier full migration still works. For
providers that serve SIE over the API (Fortnox, Briox, Björn Lundén,
WINT) the 409 message points at the wizard checkbox instead of "ladda
upp en SIE-fil". The options step shows one attn line and disables
"Starta migrering" when SIE is unchecked, never imported, and any API
import is selected, so the user sees the rule before the run.

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

* fix(arcim-migration): address review findings (#2000)

- Findings 1, 3, 4 (stale hasSieData on retry): the options step now
  derives "SIE already imported" from the preview OR this session's
  successful /import-sie results, so an entities-only retry after a
  phase-2 failure is no longer blocked with a false "not imported yet"
  hint and a forced replace-mode ledger re-import.
- Finding 2 (company-info-only run gated): importCompanyInfo is dropped
  from the wizard gate and POST /migrate skips the SIE guard when every
  entity flag is false. Company info writes no accounts, balances or
  subledger rows, so the BFL rationale does not apply. Tests pin both
  the company-info-only pass and the block when any entity flag is set.

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-28 16:52:12 +02:00
Mattsson 7993700589 fix(enable-banking): only hard-warn about sibling connections at observed one-session banks (#2005)
* fix(enable-banking): only hard-warn about sibling connections at observed one-session banks

The same-bank warning dialog fired for every bank whenever the user's other
companies held a connection, claiming the siblings could stop syncing. That is
only true for banks binding one active AIS session per PSU (observed: SEB).
Prod shows Handelsbanken tolerates at least four concurrent sessions syncing
daily, and the generic warning made a multi-company user abandon a legitimate
renewal of an expired Handelsbanken connection.

The decision now lives in a pure, tested module (connection-warning.ts):

- One-session banks (SEB): hard warning on fresh connect and renewal alike.
- Other banks, renewal: no dialog; the connection already coexisted.
- Other banks, fresh connect: calm confirmation so a user who meant to renew
  notices they are about to create a second connection.

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

* fix(enable-banking): fail closed for banks with unknown session policy, exempt shared-session siblings

Skeptic findings on the first cut:

- Unknown banks were treated like verified multi-session banks (silent
  renewal), turning absence of evidence into evidence of absence. They now
  keep the previous hedged warning on both paths; only Handelsbanken
  (verified 2026-08-28: 2-4 concurrent connections per user on DISTINCT
  session_ids, all syncing daily) gets the calm tier.
- Siblings sharing the session being renewed are carried by
  fanOutSessionRenewal and never break, so they no longer trigger or
  inflate the warning; the absolute SEB claim is accurate for the
  remaining, genuinely separate sessions.
- Company names are deduped and phrasing follows distinct company count
  (one company holding privat + foretag rows is "ett annat bolag").
- Null bank_name no longer throws (DB column is nullable).

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

* fix(enable-banking): count clashing companies by id, names are display-only

CodeRabbit: two distinct companies sharing a name, or one whose name fails
to resolve, undercounted to "ett annat bolag". SameBankClash now carries
companyId as identity; the parenthetical name list stays deduped display.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:31:38 +02:00
Mattsson 57d4359d1a feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates

Users cannot delete or hide the 26 standard konteringspaket, which clutter
the settings panel and every template picker. Deletion stays off the table
(shared global rows); instead a company can now hide individual system
templates for itself only.

- New booking_template_hidden table (insert=hide, delete=unhide), RLS gated
  on active company + write role; nothing hidden by default
- POST/DELETE /api/settings/booking-templates/[id]/hide (system templates
  only; company/team templates keep their real delete path)
- List route decorates rows with per-company is_hidden; pickers filter them
  out; the settings panel shows hidden ones in a collapsed restore section
  so hiding is never silent
- Classified in full-archive-export exclusions (UI preference, not
  rakenskapsinformation)

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

* fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book

Skeptic + CodeRabbit findings on #2004, one pass:

- hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no
  UPDATE policy on purpose, so the DO UPDATE conflict arm turned a
  concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape
- bth_insert policy additionally requires the referenced template to be an
  active system template (migration is unmerged, edited in place); negative
  pg test for company templates
- BulkBookDialog excludes templates hidden by the company (was reading the
  table directly and ignoring hides)
- panel shows the failure toast when the hide/unhide fetch itself rejects
- picker category chips built from the hidden-filtered list

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:12:47 +02:00
Mattsson 5720632832 fix(transactions): stop inbox row markers overlapping Belopp on mobile (#2003)
* fix(transactions): stop inbox row markers overlapping Belopp on mobile

The inbox row's inline markers (pre-migration marker, edited badge,
1930<->1630 badge) are shrink-0 inside a max-w-0 description cell, so on
narrow viewports they overflowed the cell and painted over the amount
column. Gate them behind md: (the same pattern TransactionHistoryList
already uses), clip the description cell with overflow-hidden as a
backstop, and surface the pre-migration context in the row foldout so it
stays reachable on mobile.

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

* fix(transactions): keep edited-title state reachable on mobile without original name

CodeRabbit review: the PATCH route can set title_edited_at while
original_description is null, and below md both the inline marker (now
hidden) and the foldout line (required originalName) disappeared. The
foldout now opens on title_edited_at alone and falls back to a generic
edited-title line when the original bank name is missing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:04:33 +02:00
Mattsson 52e99295de fix(white-label): accept byrå-team invites before landing, so admins reach /clients (#2002)
A newly-invited byrå admin/member who signed up with email+password landed
on /onboarding instead of the cockpit. Root cause: team-invite acceptance
lived only in POST /api/team/accept, which the email-confirmation signup flow
never reaches before the dashboard (no session for the register page's
client-side accept), while the auth callback and the onboarding/select-company
recovery only understood company_invitations. So the invitee's byrå membership
did not exist when landing resolved, and they were funneled into creating a
company.

- New shared helper acceptPendingTeamInviteByToken (lib/company/pending-invites)
  is the single server-side implementation of team-invite acceptance.
- POST /api/team/accept delegates to it; HTTP contract unchanged.
- /auth/callback accepts a team invite BEFORE the silent-team check and before
  resolveLandingDestination runs, so an owner/admin resolves to /clients; the
  invite cookie is cleared on success, kept otherwise for the retry.
- acceptPendingInviteByToken (onboarding/select-company recovery) tries the
  company path, then falls back to the team helper.
- hasPendingInviteForEmail checks both invite tables, so a tokenless byrå
  invitee is not misread as a first-timer.

No migration (team invite tables already exist). Company-invite and
non-invite flows are untouched.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:08:04 +02:00
Jakob Wennberg 89d0e1b994 fix(mcp): promote gnubok_update_customer to the default tools/list catalog (#1986)
* fix(mcp): promote gnubok_update_customer to the default tools/list catalog

The tool existed since #1876 but was catalogVisibility 'search', which
means it appeared only through gnubok_search_tools. Claude.ai (and other
list-driven clients) can only call tools present in tools/list, so on the
primary connector the tool was uncallable and the reporter of #1706 read
it as missing twice.

- server.ts: drop the 'search' flag on gnubok_update_customer; no change
  to schema, executor, scopes, risk tier, or staging path
- update-customer.test.ts: pin the tools/list projection
  (isDefaultCatalogTool) instead of the flag; keep the search test
- payload-size.bench.test.ts: ceiling 63_400 to 64_100 (measured 64 043,
  +761 over main) with a progression bullet
- README.md: note that search-only tools are not callable from Claude.ai
- DECISIONS.md: supersede the 2026-08-25 keep-search-only entry

Closes #1706

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

* test(mcp): re-measure the tools/list ceiling after #1993 landed on main

#1993 declared line_type and revenue_account on the create item schema,
which moved the accounted projection to 63 761 with gnubok_update_customer
promoted; ceiling 63_800 keeps the same headroom as before.

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:28:14 +02:00
Jakob Wennberg 17caf9d80a feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview

gnubok_update_invoice items are a FULL REPLACE, had no article fields, and
no MCP tool returned invoice lines, so a quantity fix rebuilt from memory
wrote article_id/revenue_account null and reverted vat_rate to the customer
default: revenue silently moved from the article account (3041) to the
VAT-derived default, invisible in the approval preview.

- gnubok_get_invoice (invoices:read, search-only): header plus every line
  with article_id, revenue_account, vat_rate, dimensions, editable_draft
- gnubok_update_invoice lines accept article_id with the same prefill and
  default-set VAT adoption guard as create; permitted-set VAT gate at
  staging; preview carries the new lines' effective booking and a snapshot
  of the lines being replaced
- commitUpdateInvoice scope-checks staged article ids like create does
- OperationPreview: update_invoice preview (current vs new lines, header
  diffs, totals); create_invoice lines show VAT rate and posting account
- invoicing skill points at the read-before-replace round trip

Closes #1642

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

* fix(mcp): use roundOre for update-invoice preview totals so the ore ratchet stays at baseline

The preview-building code in gnubok_update_invoice introduced five naive
Math.round(x * 100) / 100 occurrences, tripping check:guards
(naive-ore-round 627 vs baseline 622) and failing Core Build on PR #1993.
roundOre from @/lib/money is the sanctioned helper and was already
imported in this file.

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

* fix(mcp): make the invoice round trip lossless for text, ROT/RUT and accrual lines

Skeptic review of #1993 found three round-trip breaks for web-created
drafts edited via MCP (the exact silent-loss class issue #1642 reports):

- Text rows: the update pre-gate and resolveInvoiceLineFromArticle
  rejected quantity <= 0 before looking at line_type, so any draft with
  a free-text spacer row could not be edited at all, and the natural
  agent recovery (drop the row and retry the FULL REPLACE) silently
  deleted invoice content. Text rows are now exempt from the
  quantity/description/unit/price gates (CreateInvoiceItemSchema
  parity), normalized to the zeroed stored shape, excluded from the
  staged totals and the VAT gate (commitCreateInvoice billableItems
  parity), and line_type is declared on both the create and update item
  schemas.

- ROT/RUT: gnubok_get_invoice omitted housing_designation,
  apartment_number and brf_org_number, so an items replace on a ROT
  draft either failed AFTER approval ('Fastighetsbeteckning krävs för
  ROT-avdrag') or, for a schema-conformant agent, silently stripped the
  avdrag and the stored personnummer. The three property columns
  (property identifiers, never the personnummer ciphertext) are now
  returned per line, the deduction fields are declared on the update
  item schema, deduction_type rides on the current_items snapshot and
  the new-lines preview, and a staging-time completeness gate
  (arbetstyp/timmar via validateDeductionLines, fastighetsbeteckning
  for ROT, personnummer availability on the invoice or the individual's
  kundkort) surfaces the failure to the agent instead of the approver.

- Declared-schema gap: revenue_account and the accrual fields were
  accepted on pass-through but undeclared, so a schema-conformant agent
  dropped a manual posting-account override or a periodisering on
  pass-back. They are now declared on the update item schema
  (revenue_account also on create; create deliberately does NOT declare
  deduction/accrual fields because commitCreateInvoice drops them), and
  the approval preview shows ROT/RUT-avdrag and the periodisering
  period per line.

tools/list ceiling check after the two new create-schema properties:
63337 of 63400.

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:27:02 +02:00
Jakob Wennberg 273af39994 fix(whatsapp): company question falls back to numbered text and ignores archived companies (#1992)
* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589)

The linked multi-company sender in #1589 never heard back because Meta
rejected the reply-button payload synchronously (HTTP 400, #131009
"Duplicate button title"): the sender belongs to two companies with the
same name, one of them archived. askCompanyQuestion rolled the question
back and returned not_asked, the row stayed parked as
staged_awaiting_company, and the channel went silent.

- Exclude archived companies wherever the channel resolves memberships
  (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice,
  the M3 greeting count), same inner-join filter as the middleware.
- uniqueTitles: interactive button/row titles are made unique (position
  suffix) so two live same-named companies, or names that truncate to the
  same prefix, no longer trip #131009.
- Numbered-text fallback: when the interactive send is rejected at send
  time, ask the same M6 question as plain numbered text; roll back only
  when that fails too. A typed digit is recorded as via='numbered'.
- Drain: when the sender now resolves as 'single', rows parked behind the
  dead question are re-opened and kicked instead of expiring at Meta.

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

* fix(whatsapp): single-company drain also clears the dead company question (#1589)

Re-opening the parked rows left the conversation in state awaiting_company
with company_options and the company pending_question intact, so the sender
stayed behind a zombie question for up to 48h: every typed word became a
company_retry re-offering the archived company, 'byt' was swallowed, and
finalizeBurst could not ask about the drained receipts until the TTL sweep.

- After the drain, when a company question is open in any of its shapes
  (awaiting_company state, kept company_options, company pending_question),
  clear it through the guarded updateConversation: state -> idle, options and
  the company pending_question deleted, other question types untouched.
- Tests: the clear in its awaiting_company and post-TTL (idle + options)
  shapes, and its no-op for a representation question.

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:26:38 +02:00
Jakob Wennberg 57ff2eda96 fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1991)
* fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1599)

When check_and_increment_whatsapp_sender_quota errored, handleUnknownSender
logged, wrote a fail-closed trace row and returned: a transient DB hiccup
silenced a first-time sender at the exact moment they were trying to link.
The limiter being unavailable now falls through to the existing greeting
path, whose own throttle (1 M1 per hour for text, 10-minute media burst,
3 per day, fail-closed on its own read error) and the single-use link-code
claim already bound outbound volume. A thrown RPC (network) is treated the
same as a PostgREST error.

Over-quota (ok: false) is untouched: silent by design, decline trace kept.
In degraded mode a valid code still binds and gets M3; a bad code gets the
throttled M1 instead of M2, because only the quota bounds M2. The greeting
dispositions carry a ' (quota limiter unavailable)' suffix so support can
tell the two modes apart; suffix rather than prefix because last-event.ts
matches dispositions with startsWith.

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

* fix(whatsapp): answer a bad link code with a throttled M2 in degraded mode

Review finding on #1991: withholding M2 while the quota RPC is down left
the worst sub-path of the linking moment silent. A sender greeted with M1
inside the last hour who then sends an expired or mistyped code fell
through to the greeting path, where the 1/hour rule declined M1, so they
heard nothing at all; exactly the silence issue #1599 targets.

M2 now gets its own small bound instead of being withheld: a new
badCodeThrottled read in lib/conversation.ts (mirrors greetingThrottled,
keyed on raw_payload->>template = m2_bad_code: 1 per 10 minutes, 3 per
day per phone hash, fail-closed on read error). In degraded mode a bad
code sends M2 when that throttle allows and otherwise falls through to
the existing M1/silence path. The normal path is untouched: the
short-circuit only does the extra read when the quota RPC was
unavailable. The M2 trace disposition carries the degraded suffix.

Tests: the replaced "withholds M2" case now asserts M2 goes out once
under its own throttle with the degraded trace suffix; new cases cover a
repeated bad code inside the 10 min window (silent skipped trace via the
greeting throttle), the 3/day cap, and an unreadable M2 window failing
closed to the M1 path.

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:29 +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 cb9ae15d46 fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora

reverseEntry() unlinked bank transactions from the reversed entry by
clearing only journal_entry_id. The worklist's "unbooked" predicate is
is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so
the row stayed "handled": absent from Att bokfora and from the nav badge,
while the storno dialog (reverse_warning) promised the opposite (#1950).

The engine now resets the same triple the uncategorize paths write
(journal_entry_id, is_business, category) plus reconciliation_method,
scoped to rows linked to the reversed entry. Fixed in the engine so the
dashboard, v1 and MCP reverse doors all agree.

Closes #1950

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

* fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links

The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked
samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows
through transaction_voucher_links only (journal_entry_id stays NULL), so the
reset matched nothing there: all rows kept is_business = true against a
status='reversed' entry, stayed out of Att bokfora and the nav badge, and
is_transaction_booked() still reported them booked. The N=1 variant left a
dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and
kept the reconciliation bridge bucketing the row as matched.

reverseEntry now deletes the reversed entry's junction rows (the same removal
koppla-bort performs) and releases is_business, category and
reconciliation_method only for rows left with no anchor: a remaining-links
read plus journal_entry_id IS NULL guards residual bookings (main verifikat
in journal_entry_id, junction row to the residual verifikat) and
multi-allocated rows so stornoing one voucher never unbooks a still-booked
row.

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

* fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink

The widened reverseEntry reset (#1950) now nulls is_business, category
and reconciliation_method together with journal_entry_id on the linked
transaction, but the fix-cash-mismatch remediation relinked with only
journal_entry_id. The repaired row ended up booked (pointer at the
posted clearing entry) yet visible in Att bokfora and the nav badge
(worklist predicate: is_business IS NULL), the inverted #1950 symptom;
booking it from the list would conflict-storno the correct clearing
entry and corrupt the AR chain the route just repaired.

The relink now restores the full booked triple, mirroring the
match-invoice route's final update. New route tests cover auth 401,
validation 400, the no-targets path, and assert both relink payloads.

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:24:55 +02:00
Jakob Wennberg 533df34369 fix(payments): make supplier payment batch creation atomic via create_supplier_payment_batch RPC (#1989)
createSupplierPaymentBatch wrote the batch header and its items as two
separate PostgREST inserts, and the active-batch recheck ran app-side
before either. Two concurrent creates selecting the same invoice could
both pass that check and both land an active batch without
confirm_already_batched, and an item-insert failure after the header
landed could leave an empty 'created' batch behind when the best-effort
cancel also failed.

The new SECURITY DEFINER RPC is now the single write path: it locks the
selected invoices FOR UPDATE in id order, re-checks payability, amounts
and active batches inside the transaction, and inserts header + items
together so a constraint violation rolls both back. TypeScript keeps the
shared eligibility evaluation and the msg_id minting (branding lives in
TS); the service result union is unchanged so the route and UI are
untouched.

Closes #1503


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:24:36 +02:00
Jakob Wennberg 4f939ebb21 fix(payroll): expose jämkning percentage and validity on the employee tax form (#1988)
* fix(payroll): expose jämkning percentage and validity on the employee tax form (#1913)

An employee with a Skatteverket jämkning decision could not have the
adjusted withholding percentage set anywhere in the app: model, API and
engine supported jamkning_percentage / jamkning_valid_from /
jamkning_valid_to end to end, but EmployeeTaxCard never exposed them.

- EmployeeTaxCard: percentage input plus required from/to dates in the
  A-skatt branch; null (= clear the beslut) when emptied or when no
  table applies, mirroring tax_table_number. Both dates are required
  because isJamkningValid only applies a beslut when both are set.
- Edit page: PATCH body sends the three fields as explicit values
  (guarded on the card having reported), card initial seeded from the
  employee, read-only Jämkning row in the tax section.
- NewEmployeeDialog: initial tax state and POST body carry the fields.
- Legacy PATCH /api/salary/employees/[id]: merged-state jämkning check
  (start date required, dates ordered), same rule and messages as v1
  and employee-commands, gated on the PATCH touching a jamkning key.
- lib/api/schemas.ts: truthful comment on the engine's both-dates gate.
- i18n: salary_employee.tax_jamkning_* in sv and en.
- Tests on the legacy PATCH route (400 x4, 200 x3) and the POST route.

The engine is deliberately untouched; the API/MCP contract (valid_to
optional) stays as is, follow-up filed in the PR body.

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

* fix(payroll): jämkning keys reach the employee PATCH only when visible and edited (#1913)

Review findings on #1988: the card reported null for the three jämkning
fields whenever its inputs were hidden (sidoinkomst, F-skatt, FA-skatt,
ej verifierad) and the edit page forwarded those nulls, so toggling
sidoinkomst or fixing a phone number on an FA-skatt employee silently
wiped a stored beslut (which the engine still applies for FA-skatt).
The two date inputs were also natively required whenever a percentage
was present, so a beslut stored via the API/MCP without valid_to
(allowed by the schema) blocked the whole form on unrelated edits.

- lib/salary/jamkning-patch.ts (new): isJamkningEditable() and
  jamkningPatch(); the keys are spread into the PATCH body with explicit
  values (null = clear) only when the inputs were visible and edited,
  otherwise omitted like every other sparse field.
- EmployeeTaxCard: jamkning_touched flag on EmployeeTaxValue, set by the
  three handlers; required on both dates gated on it; non-blocking hint
  (tax_jamkning_incomplete_hint, sv + en) on a seeded beslut missing a
  date.
- Edit page spreads jamkningPatch(tax); NewEmployeeDialog initial state
  carries the flag.
- Tests: lib/salary/__tests__/jamkning-patch.test.ts (keys omitted for
  sidoinkomst / f_skatt / fa_skatt / not_verified / untouched seeded row,
  explicit nulls when cleared, spread shape).
- DECISIONS.md: one line.

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

* test(payroll): type the insert mock's payload so the typecheck ratchet accepts the jamkning tests

vi.fn(() => ...) infers an empty parameter tuple, so insert.mock.calls[0][0]
failed TS2493 under the new check:types gate (#1980) on CI. Declaring the
payload parameter keeps the assertions and makes the tuple indexable.

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:24:16 +02:00
Jakob Wennberg 175bb8bd92 fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business
side regardless of sign, so a learned line_pattern whose ratios
over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02)
produced an entry off by 2x|diff|. commit_journal_entry rejected it, so
the user saw a failed confirm and, since #1894, an unbalanced prefill.

The 3740 leg now lands on the business side for a positive diff
(under-allocation, unchanged) and on the opposite side for a negative diff
(over-allocation), flipped after the mirror. computeProposalLines gets the
identical rule in the same change to keep the byte-parity contract, and a
5000-amount sweep test pins engine and proposal together. Also reachable
with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02.

Closes #1898


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:23:52 +02:00
Jakob Wennberg 04406edb0f fix(mfa): hard-navigate after TOTP enrolment so the user is not stranded on the QR screen (#1948) (#1984)
leave() in /mfa/enroll ended with router.push(returnTo) followed by
router.refresh(). Enrolling raises the session to aal2, which
lib/supabase/middleware.ts only re-evaluates on a fresh document request;
the push and the refresh raced, the refresh won, and the user was left on
the QR screen with 2FA already active and no way forward but the address
bar. Always window.location.assign(returnTo) instead, the way the same
function already did for /api/ destinations and /mfa/verify does for its
invite and route-handler paths. returnTo is already validated by
safeReturnTo, so the unconditional hard navigation stays same-origin.


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:19:20 +02:00
Mattsson fca57dc470 fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes (#1998)
* fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes

The period picker re-seeded from scratch on every visit: an arsmoms user
whose moms_period was never set landed on a silently guessed quarterly
declaration (companies without a company_settings row bypassed every
gate), and a manually chosen cadence evaporated on the next visit.

- Gate the view when no company_settings row exists, matching the
  existing "registered but no period" gate: a declaration for the wrong
  period type is a compliance hazard, not a convenience.
- Persist the manually chosen cadence per company (localStorage,
  FyPicker pattern) and restore it while moms_period is unchanged; the
  concrete period still re-seeds to the most recently ended one, and a
  changed setting discards the stored cadence.
- Extract the seeding decision into lib/vat/period-selection.ts with
  unit tests.

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

* fix(vat): drop cadence persistence; the moms_period re-seed is the control

Skeptic review refuted the persistence half of the previous commit twice:
the render-phase localStorage restore diverged from SSR (hydration error
on every visit once a cadence was stored), and restoring a manually
chosen cadence that deviates from moms_period kept the filing pipeline
open on the wrong period type across visits, with no downstream path
validating period type against the setting.

The redovisningsperiod has exactly one lawful value per company, so the
mount-time re-seed from company_settings.moms_period is the self-healing
control, not a bug. The settings-row gate and the extracted, tested
seeding resolver stay.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:09:47 +02:00
Mattsson 4f6ecad549 feat(white-label): invite-only signup for brand domains (#1995)
* feat(white-label): invite-only signup for brand domains

A brand domain belongs to the partner's people (founder decision
2026-08-27): only allowlisted or invited users may create an account on
an invite-only brand domain; everyone else is shown an interstitial that
sends them to the canonical Accounted signup.

- brands.signup_mode ('open' default / 'invite_only') +
  brand_signup_allowlist (lowercase emails, team-scoped RLS, owner/admin
  writes) + create_company_for_brand_signup RPC, with pg-real coverage
- server-side gate (lib/auth/brand-signup-gate.ts) enforced on every
  signup path: email signup moved to POST /api/auth/signup (the browser
  used to call GoTrue directly, so a client-side check would be
  bypassable), BankID gated in /bankid/complete, Google covered by the
  dashboard layout's brand-domain bounce
- company invites bypass the allowlist: the invite is the authorization
- register page interstitial on gated brands (no email in the outbound
  URL), sv+en strings
- dashboard layout bounces non-belonging sessions off gated brand hosts
  to the canonical domain (navigation rule like WL-01, not a security
  boundary)
- allowlisted signups' onboarding-created companies attach to the
  brand's byra team via the new RPC, so WL-01 homes them on the brand
  domain; the allowlist entry recorded by an owner/admin stands in for
  the WL-15 admin gate
- byra cockpit page /clients/access + /api/clients/signup-access to
  manage the mode and the allowlist

All existing brands default to 'open': behavior is byte-identical until
a brand is flipped to invite_only.

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

* fix(white-label): rollback brand-signup company with the service client

Skeptic (correctness) found that a brand-signup company created under the
service role rolled back with the cookie-session client: `companies` has
RLS and no FOR DELETE policy, so the delete was a silent 0-row no-op,
stranding a member-less ghost company on the partner's byra team. Pass an
optional rollbackClient to createCompanyCore and hand it the service
client on that path; user_preferences.active_company_id then clears itself
via its ON DELETE SET NULL FK once the company row is actually deleted.

Also map a validateBody 400 (flat envelope, no code) on the register page
to the specific email-invalid field message instead of the generic one,
since the client already pre-gates password strength.

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

* fix(white-label): fail-safe brand lookup, pg-test seed, anonymize fixtures

Second resolve-pr cycle: skeptic + CodeRabbit findings and a green-up.

- Fail safe on a brands-table error (CodeRabbit CWE-285): the gate treated a
  failed resolveBrandByHost as an unbranded host, opening invite-only signup
  during a transient DB blip. resolveBrandResultByHost now distinguishes
  "no brand" from "lookup failed"; the gate returns lookupFailed and the
  email + BankID routes answer 503 (retry), never creating an account.
- pg-real: the RLS delete test seeded its row inside withUserContext, which
  always rolls back, so the owner DELETE saw zero rows. Seed on the superuser
  pool instead.
- Anonymize every test/fixture brand to the repo's existing synthetic
  placeholder (Siffra / app.siffra.se): no real partner names in code.
- SignupAccessManager: functional setData updates so a concurrent mode
  toggle and an add/remove do not clobber each other's snapshot (CodeRabbit).
- Route a transient-error message through i18n instead of the raw envelope
  (raw-user-error guard); new register.error_temporary sv+en.

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

* test(white-label): anonymize new signup-gate fixtures; log oracle residual

Rename the placeholder brand in the four new brand-signup test files to a
clearly-fake, partner-unrelated name (Testbrand / app.testbrand.example);
the previous placeholder echoed a real partner. Scoped to files this PR
creates; the repo-wide legacy placeholder is left for a separate cleanup.

Also record in DECISIONS.md that the feature ships accepting the
low-severity allowlist-enumeration residual (captcha-free 403 vs 200 on
the signup endpoint), with rate-limiting as the follow-up option.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:32:52 +02:00
Jakob Wennberg 325c827322 test(mcp): run the tools against a real PostgREST, not a fake supabase (#1983)
All 100 files in extensions/general/mcp-server/__tests__ fake supabase.
query-journal.test.ts says out loud that its query chain is "exercised by the
live MCP smoke test", and no such test exists in CI. So the PostgREST grammar
of 157 tools, every .select() column string, every resource embed, every
or=(...) form, is gated by nothing and fails first in production.

pg-real cannot cover this: it holds a pg Pool and writes SQL, and none of that
grammar is resolved by Postgres. It is resolved by PostgREST at request time.

Adds a tool-pg vitest project, a docker-compose stack, a reset script that
replays every migration the way the pg-real CI job does, and a CI job.

The first sweep covers 74 read tools and finds no malformed query, across 87
real requests. That number is honest rather than impressive: with an empty
argument set many tools bail before querying. Per-tool fixtures are what
deepen it, and this harness is what makes writing them worth the effort.

Includes a self-test that injects a bad column and asserts the harness detects
it. That is not ceremony. It caught this file passing green while exercising
nothing, twice: once locally where supabase-js prefixes /rest/v1 onto a bare
PostgREST that does not serve it, and once on CI where Node 20 has no native
WebSocket, so every client construction threw and was swallowed by the
per-tool catch as a domain refusal. The client is now built once outside that
catch, the proof-of-life assertion counts real requests instead of being
trivially satisfiable, and realtime gets an inert transport.

Also excludes .next from all three vitest projects. These projects override
vitest's default excludes, so a local `npm run build` leaves a traced copy of
the repo that gets collected as a second set of test files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:11:41 +02:00
Jakob Wennberg 5fe3ae71a1 feat(mcp): surface documents that are attached to nothing on the attention resource (#1979)
A document_attachments row is reachable from eight places. A row referenced by
none of them is stored, retained for seven years under BFL, and connected to no
bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across
210 companies, 481 of them in the preceding week.

The naive predicate is a trap. Without a mime filter the same query returns
15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are
unlinked by design. Putting them on an orientation surface would hand an agent
eleven thousand items of work it must not do, which is worse than showing
nothing.

So the rule is an allow-list of the mime types an underlag can actually be.
Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and
pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps
the next machine-payload format out by default rather than after someone
notices it leaking.

Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter
first, then eight reference lookups that run only when candidates exist, so a
company with none costs exactly one query. The scan cap is set by URL length
rather than table size, because every candidate id is echoed back through those
eight .in() lookups.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:00:27 +02:00
Jakob Wennberg 304baf1089 chore(ci): ratchet TypeScript errors, because npm test does not typecheck (#1980)
Vitest transpiles and discards types, so a type error passes all 18 000 tests
and only surfaces in npm run build several minutes later. That happened twice
on 2026-08-27: a widened union in the MCP server that a second declaration in
lib/events/types.ts still contradicted, and an interface that would not assign
into Record<string, unknown>[] because interfaces have no implicit index
signature. Both were caught by the build. Neither was caught by the tests,
which is the wrong order to learn it in.

This is not just a faster copy of the build job. tsc --noEmit also covers
__tests__ files, which the Next.js build never compiles, and that is where all
539 baseline errors live.

Baselined per FILE rather than per error code, unlike the lint ratchet: the
legacy errors sit in a handful of old test files and TS2322 is common enough
that a code-keyed budget would let a real regression hide behind a legacy fix
somewhere else.

Measured: 36s cold, which is what CI pays, and 4.4s warm locally.

Verified the gate fires by introducing a deliberate type error and watching it
fail with the exact location, then restoring.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:53:04 +02:00
Mattsson e41cf50afe fix(bookkeeping): verifikat views show company account names for off-catalog accounts (#1997)
* fix(bookkeeping): verifikat views show company account names for off-catalog accounts

Verifikat views (entry detail, JournalEntryList, StrikeLinesDialog,
CorrectionPreview) rendered AccountNumber without a name source, so
accounts outside the BAS 2026 catalog (typically SIE-imported, e.g. a
legacy 1580) showed a blank Benamning even though the company's
kontoplan has the name.

AccountNumber now falls back to a session-cached map of the company's
own chart-of-accounts names (one fetch per page load, inactive accounts
included for historical verifikat). Precedence: explicit name prop,
then the company map, then the BAS reference name, keeping the
PR #1968 rule that user-editable chart rows win over hardcoded copy.

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

* refactor(bookkeeping): read company account names via the shared reference-data cache

The check:guards raw-reference-fetch ratchet rightly rejected the
hand-rolled session cache: lib/reference-data/hooks.ts already provides
useAccounts with a per-company SWR cache seeded by the dashboard layout.
AccountNumber now derives the company name from useAccounts(false)
instead, and the bespoke hook and its tests are removed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:45:52 +02:00
Jakob Wennberg 12ce693eb6 feat(mcp): make search-only read tools reachable, and put the payload ceiling into reverse (#1976)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

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

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

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

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

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

* feat(mcp): make search-only read tools reachable, and put the payload ceiling into reverse

DECISIONS.md records on 2026-08-26 that gnubok_reconcile_match had to be
promoted back into the default catalog because "a search-only tool is
uncallable on Claude.ai". That is a client-side limit, not a server one: the
tools/call dispatcher has always resolved names against the whole tools array,
and isDefaultCatalogTool gates only what tools/list shows. So
catalogVisibility: 'search' was unusable as a payload lever for reads, and the
ceiling could only ever go up.

gnubok_call_tool gives such a client one visible name to forward through. It
is a rewrite in the dispatcher rather than a forwarding wrapper: {tool,
arguments} is rebound to the inner tool BEFORE resolution, so the scope check,
unknown-argument guard, company routing, test-key write block, staging _meta
and telemetry all apply to the real target instead of being bypassed. Reads
only; a write must be named directly so its approval contract stays visible.

Alongside it, gnubok_get_agent_briefing's outputSchema drops 7743 to 4565
chars. Four sub-schemas whose interiors were documentation rather than
contract are condensed to a permissive object plus a fuller description;
agent-briefing.test.ts already pins their runtime shape, so nothing is left
unguarded.

Net on the guarded (accounted) projection: 63 491 to 62 942 tokens, with the
new tool included. The ceiling moves 63.6K DOWN to 63.1K, the first tightening
in that ledger, and the note now says to demote a read before proposing a bump.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:45:40 +02:00
Jakob Wennberg 3447da027a feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

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

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

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

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:35:47 +02:00
Jakob Wennberg c0ecb34a2b fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices (#1977)
* fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices

The 2026-08-18 eligibility rule stopped the archive-and-replace reset before
the first journal entry, voucher sequence, or invoice. The reset deletes
nothing: the source stays a write-closed, downloadable retention container,
and the unchecked Radera foretag path already produced the same outcome
without any of those guards, so the blockers only led owners into a dead
end (Carrierstories, 2026-08-26). External-state blockers are unchanged.

Closes #1916

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

* docs(decisions): move the migration-reset entry to the end of the log

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-27 14:35:19 +02:00
Jakob Wennberg dfed55cb6c feat(periods): undo klarmarkera so an externally closed year can be reopened (#1978)
markPeriodClosedExternally ("klarmarkera") closes and locks an imported
year without a closing entry, and nothing could reverse it: unlockPeriod
refuses closed periods and the SIE replace flow refuses closed or locked
years. An owner who klarmarkerade five imported years and then found the
prior-year SIE file was wrong had no way back (Forsslund Systems,
2026-08-27).

reopenExternallyClosedPeriod reverses the mark while the closed state still
comes from klarmarkera (closed_externally set, no closing entry), clears the
lock, writes the audit_log row, and emits period.unlocked. New route
POST /api/bookkeeping/fiscal-periods/[id]/reopen-external with envelope codes
PERIOD_REOPEN_NOT_CLOSED / PERIOD_REOPEN_NOT_EXTERNAL; "Öppna igen" action
and "Avslutat i tidigare program" chip in Settings > Bookkeeping > Fiscal
years; unlock and SIE replace refusals now point at that path.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:34:02 +02:00
Mattsson c981384a0e fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow

Three gaps around the agent sheet during booking on Transaktioner:

- The AgentTrigger bubble lived inside #dash-shell, so the inert that
  non-modal dialogs set on the shell made the assistant impossible to
  OPEN once a booking dialog was up. Moved outside the shell (it is
  position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so
  it stays clickable, below dialog content (z-50). Under true modal
  dialogs Radix's body pointer-events lock keeps it dead as before.
- Wide dialogs centered on the full viewport while the docked sheet
  (z-60) covered their right edge, hiding e.g. the Granska button.
  DialogContent now centers in the space left of --agent-dock-w, and
  the wide booking/review variants cap their width against it.
- Step 1 of the flow (template picker, QuickReviewDialog) was still
  fully modal, so the assistant was dead there. Both are now non-modal
  with DialogVeil + a shared ref-counted useDashShellInert hook (also
  replacing the duplicated inert effects in TransactionBookingDialog
  and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they
  hold no half-filled form. Clicks in the agent sheet or its trigger
  never dismiss any dialog: data-agent-ui counts as inside, same
  mechanism as data-dialog-companion.

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

* fix(ui): dialog centering reads a docked-only sheet width variable

Skeptic review refuted the first cut: globals.css seeds --agent-dock-w
at the 10px frame gutter on :root by design, so the 0px fallback in the
new dialog centering never applied and every dialog sat 5px left of
center (full-bleed dialogs clipped 5px off-screen).

Introduce --agent-sheet-w, set inline by AgentSheetProvider only while
the sheet is docked and removed otherwise, so the 0px fallback is real:
sheet closed or floating renders byte-identical to the old left-[50%]
and old max widths. Also cap the template picker and QuickReview's
narrow variant, which could clip off-screen left on narrow desktops
with the sheet docked.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:22:20 +02:00
Mattsson 99c94d467a fix(white-label): back-to-clients link points at the byra cockpit's home domain (#1973)
* fix(white-label): back-to-clients link points at the byra cockpit's home domain

A byra member working a company homed on another host (e.g. a pre-byra
company on canonical) got a relative /clients on the wrong host instead
of their white-label cockpit. resolveCockpitHref mirrors WL-14's home
rule: relative when the current host is the cockpit's home (brand domain,
or canonical for a brandless byra), else an absolute URL there. Cross-
host links render a plain <a> with a 'Hanteras via' hint; the hop lands
on the brand host's login (per-host sessions, WL-01) and WL-14 then
lands on /clients.

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

* fix(white-label): show the cross-host cockpit hint as visible text

CodeRabbit: title-only hints never surface on touch devices, so the
expanded-sidebar and mobile external back-links now render the
'Hanteras via {domain}' line as small muted text under the label
(same pattern as the switcher's foreign entries). Also corrects the
comments claiming the no-company branch never renders the back-link:
it can, on its cockpit/settings surfaces, where the relative fallback
matches pre-change behavior.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:44:23 +02:00
Mattsson a860c690ed feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins (#1972)
* feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins

Byra staff logging in via BankID or the Google/magic-link callback on
their brand domain landed on /select-company resp. / instead of the
cockpit, because those two paths bypassed the WL-14 landing rule.

- Extract the rule into resolveLandingDestination
  (lib/company/landing-server.ts) so server code can call it without an
  HTTP round-trip; /api/clients/landing becomes a thin wrapper.
- Auth callback: with no explicit destination, AAL1 sessions resolve the
  landing from the request host, degrading to / on any failure
  (MFA-enrolled users already get the rule via /mfa/verify).
- BankID login: byra staff on their brand host get /clients; everyone
  else keeps the deliberate /select-company picker byte-identically.

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

* fix(white-label): address PR 1972 review findings

- /api/clients/landing: requireAuth() directly instead of
  withRouteContext, which 4xxed byra staff without a company of their
  own (COMPANY_CONTEXT_MISSING) and silently sent the cockpit's primary
  persona to /select-company. MFA enforcement unchanged.
- landing-server: log the byra membership query error before degrading
  to '/' so a persistent failure is distinguishable from no membership.
- Deduplicate the clientWithTeamMembership test mock to file scope.

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

* fix(white-label): paginate the byra membership query

fetchAllRows per repo convention: PostgREST silently caps unpaginated
selects at 1000 rows, which could hide a qualifying owner/admin
membership. Errors still degrade to '/' with a log.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:37:20 +02:00
Mattsson b30c71086e feat(byra): gate the automatic cockpit landing to owner/admin (#1970)
* feat(byra): gate the automatic cockpit landing to owner/admin

Plain byra members now land like regular users; owner/admin keep the
cockpit landing at both decision sites (post-login /api/clients/landing
and the '/' bounce). The middleware zero-company steer stays ungated:
a member with zero companies has nowhere else to land. Cockpit access
itself is unchanged (nav + /clients remain membership-based).

Supersedes the 2026-08-05 all-members widening (DECISIONS.md).

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

* fix(byra): keep byra members out of the first-run wizard on auto-landing

Skeptic finding: a member whose auto-resolved active company is
onboarding-incomplete (e.g. mid migration-reset, which repoints
active_company_id itself) fell through the new role gate into
/onboarding, a dead end for role member (WL-15 refuses client
creation). Byra members without a picked-company cookie now go to
/byra at the onboarding check, restoring the pre-gate shield.

Also pins the role column into the landing route's select assertion
so dropping it can't pass the mocked tests silently.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:49:00 +02:00
Jakob Wennberg 23776337c8 feat(onboarding): the hosted checklist closes with Connect to Claude (#1971)
Founder call 2026-08-27: the strategic split is Claude-first onboarding
for brand-new businesses (no migration friction) and hosted onboarding
for prior-system users, whose closing CTA now hands them to Claude with
everything already connected: the MCP onboarding skill then opens with
reconciliation findings and the Att göra-list instead of setup steps
(shipped in #1967).

The final checklist step 'Bygg din bokföringsassistent' becomes 'Anslut
till Claude': the button opens claude.ai's Add-custom-connector dialog
prefilled (connectorName from the brand, connectorUrl built from the
page origin so self-hosted and white-label domains link to themselves).
The in-app assistant calibration stays reachable from the assistant
page. sv + en strings added.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:35:47 +02:00
Mattsson 5e9cd6f761 fix(enable-banking): unstarve the daily bank sync cron (#1969)
* fix(enable-banking): unstarve the daily bank sync cron

The sync cron self-limited to 50s (no maxDuration export, so the route ran
under the 60s platform default) and processed ~17 connections per day
against 123 entitled active connections: any given connection only got an
automatic sync every 4-7 days, and users bridged the gap by clicking
'Synka' manually, which pushed them to the back of the queue.

- export maxDuration = 300 (Vercel Pro ceiling the code always assumed)
- sync loop budget 50s -> 230s; health probe gets the 280s leftover
- connections sync in concurrent waves of 4 with per-connection error
  isolation preserved
- MAX_CONNECTIONS_PER_RUN 50 -> 300 (safety cap only; one run now covers
  the whole entitled queue)

Cadence stays once daily at 05:00 UTC by design; users who want more can
sync manually.

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

* fix(enable-banking): serialize same-company connections within sync waves

Skeptic refutation: the post-sync unattended reconciliation sweep is
company-scoped, so two connections of one company syncing concurrently run
two identical whole-company sweeps whose unlinked-GL-line snapshots race;
both can claim the same journal entry for different bank transactions,
leaving the GL short while every surface shows reconciled.

Waves now fan out over company groups instead of raw connections: one
company's connections sync sequentially inside a single wave slot,
unrelated companies still run 4-wide.

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

* fix(enable-banking): re-check the sync time budget inside company groups

Review finding (PR Reviewer Guide): the budget was only checked between
waves, so one company with many connections could run past 230s inside a
single wave and eat the health-probe and teardown margin.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:39:29 +02:00
Mattsson 98aff7c577 fix(transactions): keep booking dialog open while typing in the agent sheet (#1966)
* fix(transactions): keep booking dialog open while typing in the agent sheet

TransactionBookingDialog was a default modal Radix dialog; the agent
sheet is a fixed z-[60] panel portaled outside its DOM, so a click in
the assistant's text field counted as an outside interaction and
dismissed the dialog, losing the half-filled booking.

Apply the established non-modal convention (NewInvoiceDialog /
NewJournalEntryDialog): modal={false} + DialogVeil, with escape and
outside-dismiss prevented. Closing is explicit via the header X.

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

* fix(transactions): restore page modality via inert while booking dialog is open

Skeptic review caught that the non-modal convention was ported
incompletely: NewInvoiceDialog pairs modal={false} with an inert effect
on #dash-shell. Without it the background page stayed keyboard-, AT-,
and (mobile nav) tap-reachable behind the veil, so a stray Shift+Tab
plus Enter could navigate away and destroy a half-filled booking.

Also corrects the comment that cited NewJournalEntryDialog as non-modal
precedent (it is plain modal).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:21:29 +02:00
Mattsson ff88e3de05 fix(bookkeeping): remove false 1580 tax-receivable label, let company account names win (#1968)
The hardcoded ACCOUNT_DESCRIPTIONS entry labeled 1580 'Fordran for skatt'
with a Skatteverket explanation. That is wrong on both counts: tax
receivables are 1640 (skattefordringar) / 1650 (momsfordran), and 1580
was traditionally 'Fordringar for kontokort och kuponger', which BAS has
since moved to 1686 (why 1580 is excluded from our BAS 2026 catalog).
Reported by a user who books card/Swish settlements there.

Also flip AccountNumber display precedence to the company's own
account_name over the hardcoded reference name: chart rows are
user-editable data and must not be visually overridden by our copy.
The tooltip keeps showing the BAS reference info.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:11:05 +02:00
Jakob Wennberg dae0aac26a fix(mcp): reconcile_match callable + two-phase efterkontroll + instant value for web-onboarded users (#1967)
* docs(plugin): starter prompt nudges the connector's onboarding guide

Several E2E runs composed the first reply from tool descriptions before
the skill loaded (questionnaire instead of the guided round). Four words
in the published prompt point the agent at the guide from message one;
the rest of the prompt stays memory-first and universal.

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

* fix(mcp): reconcile_match into the default catalog + two-phase efterkontroll + instant value for web-onboarded users

E2E #12 (the smoothest run yet) surfaced three things:

1. gnubok_reconcile_match was search-only, so Claude.ai could not call
   it: the agent misread the uncallable tool as a missing
   reconciliation:write scope and punted the entire dont-double-book
   matching step to the web app. Promoted to the default catalog
   (the standing rule: skill-instructed tools must be callable);
   ceiling 63K to 63.4K documented.

2. Skill: efterkontroll split into two phases per founder direction.
   Step 3b runs DIRECTLY after the import commits: verify the trial
   balance, sanity-read the content, and PREPARE the chart (create
   1630/8423/8314/6992 when the SIE lacks them) so the bank and
   Skatteverket connections land in a book that is ready for them.
   Step 4b after the connections: match SIE-covered bank rows with
   reconcile_match (never re-categorize), reconcile skattekontot to the
   öre, names, underlag, in a prioritized numbered list.

3. Skill: users who onboarded via the web app and connect the MCP with
   everything already set up get the reconciliation pass and Att
   göra-list as the FIRST reply instead of setup steps: immediate value
   regardless of onboarding path.

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-26 23:04:57 +02:00