Commit Graph

881 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
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 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
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 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 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 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
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 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 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 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 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
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 d8244ecaff feat(mcp): connect_migration: one-click card into the previous-system wizard (#1960)
* feat(mcp): gnubok_connect_migration: one-click connect card into the previous-system wizard

'Jag hade Fortnox' now gets the same feel as Skatteverket: the tool
returns the migration-wizard link for the named provider and renders
the connect-card widget (new migration branch: 'Hämta från Fortnox',
button opens the wizard that logs into the old system and fetches all
fiscal years plus invoices, customers, suppliers and documents). For
visma/bokio (no API export) the instructions order the SIE drop card
first and this card as the complement. Scope companies:read; skill
step 3 points at the tool instead of raw wizard links; ceiling 62.4K
to 63K documented.

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

* fix(mcp): 5-minute freshness hint on tools/list, widgets and prompts: the catalog changes with every deploy

The stateless-client CacheableResult hint on tools/list, resources/read
(widget HTML) and prompts/list was 1 hour. Claude.ai honors it, so for
up to an hour after a deploy the connector served a pre-deploy catalog:
a freshly shipped tool flapped in and out of the tool list depending on
which fetch hit the client cache, and two E2E runs dead-ended on
'tool does not exist' for a tool that was live server-side. These
payloads are static only within one deploy; 5 minutes bounds the stale
window.

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 19:19:24 +02:00
Mattsson fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

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

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

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

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

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

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

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

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

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

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

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

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

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

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

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

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

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

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

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

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

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

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

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

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

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

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

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

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

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

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

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

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

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

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

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

* fix(byra): drop the 'what's coming' tail from the automations intro

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

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

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

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

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

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

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

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

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

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

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

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

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

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

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

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

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

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

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

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

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

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

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

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

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

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

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

* fix(white-label): render hero brand logo at 64px on auth pages

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

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

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

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

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

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

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

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

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

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00
Jakob Wennberg cd9fb5b717 feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954)
From the fourth E2E run: the agent correctly refused to reproduce a
104 KB SIE file token by token (silent mid-verifikat truncation) and
dead-ended to the web wizard, and its replies were walls of compliance
prose.

1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the
   pending-document infra; .se/.sie/.si only, 50 MB HTTP cap).
   gnubok_sie_preflight and gnubok_import_sie accept upload_id as the
   byte-exact source, plus optional sha256 (hex of the raw bytes)
   verified on the upload_id/base64 paths so truncation is DETECTED,
   never silent. Inline content above 120k chars is refused with a
   pointer to the upload flow. Scope bookkeeping:write (same intent as
   import_sie).

2. Skill: brevity rule (max ~8 short lines per reply, one warning per
   step, no legal essays), memory-first rule (check what is already
   known before asking the opening questions), the upload-first SIE
   step, and gnubok_explain_voucher_gap after import for skipped
   voucher numbers.

3. CONNECTORS.md starter prompt rewritten memory-first so it stays
   copy-paste ready without the user's own data in it. Plugin v1.2.2.

tools/list ceiling 62K to 62.4K documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:11:11 +02:00
Jakob Wennberg 4af7469523 feat(onboarding): minimal input: orgnr + moms period is the whole ask (#1952)
* feat(onboarding): minimal input: orgnr + moms period is the whole ask

Two fixes from the third E2E attempt (2026-08-26):

1. accounting_method is now optional in CompanySetupSchema and defaults by
   form in planCompanySetup: aktiebolag = accrual (the norm), enskild
   firma = cash (the common small-EF choice; legal under 3 MSEK, BFL 4
   kap 4 paragraf). The plan flags the default (resolved.accountingMethodDefaulted)
   and gnubok_create_company's preview carries accounting_method_defaulted
   so the readback names it and the user overrides in the same 'ja'.
   Never silent: the preview is the checkpoint. Applies to the MCP tool
   and POST /api/v1/companies (additive; response shows the resolved
   value). The lookup tool's still_to_ask no longer lists it.

2. The agent refused a real orgnr because the user said 'nytt bolag' and
   the registry showed an established company ('Stopp. Numret matchar
   inte ett nytt bolag'): lookup instructions now state that an
   established company with F-skatt/VAT is the NORMAL case (new = new to
   Accounted) and the orgnr is never second-guessed for looking
   established.

Skill + plugin (v1.2.1) updated; API skill regenerated; DECISIONS.md entry.

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

* fix(onboarding): surface the kontantmetod 3-MSEK condition on the defaulted cash method

Compliance-review finding on #1952: the EF cash default carries a legal
eligibility condition (turnover normally under 3 MSEK, BFL 4 kap 4 §)
that a client not reading the onboarding skill would never see. The
create preview now carries accounting_method_note with the condition
whenever cash was defaulted, and the v1 pitfall states it for API
integrators. The registry cannot verify turnover, so the confirm-time
human check is the gate; the default itself stays (a brand-new EF has
zero turnover by definition).

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 15:15:09 +02:00
Jakob Wennberg 4ac7b45c8a perf(layout): dashboard layout in two waves, nav flags as one RPC, local JWT verification (#1946)
The dashboard layout runs on every hard load, hard refresh, company switch
and the 16 router.refresh() sites, and loading.tsx cannot paint until it
resolves. It cost ~20 network calls in 4 sequential waves: a third
getUser() round trip to Supabase Auth (after the proxy's and the route
guard's), the company resolution, then 16 reads including four limit-1
probes whose only job is to decide whether to render the Webshop and
Körjournal nav rows, and an entitlements read that itself ran two waves.

- lib/auth/claims.ts: claimsPinned/userFromClaims extracted from
  require-auth.ts (unchanged) so the dashboard request context shares the
  exact pinning + mapping. getDashboardAuthContext verifies the JWT locally
  and falls back to getUser() only when claims are missing, unpinned or
  unverifiable: the proxy already performed the per-request revocation
  check before the layout runs (same semantics approved for routes on
  2026-07-23).
- Wave 1 (user-keyed, parallel with the company resolution): team
  membership, profile, user preferences and the memberships join, which
  now also supplies the active company's row and role, so the separate
  companies and company_members reads are gone.
- Wave 2 (company-keyed): settings, agent profile, the switcher's settings
  names, entitlements in ONE wave (getCompanyEntitlements takes the
  team_id the join already carries and runs the grants read alongside
  config + subscription), and get_dashboard_nav_flags().
- supabase/migrations/20260826120000_get_dashboard_nav_flags.sql:
  SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies
  inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe
  fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy
  ordering) and degrades to hidden rows on any other error.
- tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs
  pending WooCommerce, active Shopify, mileage trips, RLS for a member of
  another company, EXECUTE grants. Unit tests for the wrapper (RPC row,
  single-object payload, each fallback code, other errors) and for the
  entitlements teamId option.

~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:14:49 +02:00
Jakob Wennberg 1a41119682 perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline

Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.

Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
  settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
  import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
  employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
  (the API key panel imported STAGING_SCOPES from the key generator).

BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
  dynamic import, fetched once per session after first paint; components
  that show BAS names/descriptions call useBasReference() and re-render
  when it lands. Until then (and on the server) only the hardcoded
  account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
  (account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
  scripts/generate-bas-account-numbers.ts (--check) + parity test:
  isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
  heuristic shared by the server classifier and a client variant that uses
  the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
  InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
  invoice-entries.ts, whose engine import pulled account-backfill and the
  chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
  OpeningBalanceRowEditor builds its Fuse indexes lazily; the
  ChartOfAccountsManager BAS-katalog tab awaits the chunk.

Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
  'use client' module with the shortest chain to a target (file or bare
  specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
  module reaching a Node builtin is a hard failure (0 today).

Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.

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

* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)

One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).

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 15:07:49 +02:00
Jakob Wennberg 567fae654c perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its
purest form: Bokför (TransactionBookingDialog + the embedded
JournalEntryForm) issued five requests on every open (fiscal periods,
accounts, settings, cash accounts, then the voucher preview once the first
two had landed), Nytt verifikat the same minus one, BookDirectlyDialog
four, and the template dialogs two. Each Radix dialog unmounts on close, so
every reopen paid the full price again, and several fields visibly
flipped: the bank line seeded '1930' then rewrote itself, the series
defaulted to 'A' until settings arrived, the period select was empty.

All of them now read lib/reference-data (seeded by the dashboard layout):

- JournalEntryForm: periods, accounts and settings from the hooks;
  dimensionsEnabled derived, not fetched; the voucher-number preview is
  keyed on the entry date (the route resolves the period from it) so it
  fires as soon as the series is known instead of after the period fetch;
  after activating accounts it invalidates the shared accounts cache; the
  create-period dialog callback invalidates the periods cache.
- TransactionBookingDialog: settlement account and its name derived with
  useMemo from the cached cash accounts; the form mounts on the first paint.
- BookDirectlyDialog: cash accounts, periods and accounts from the hooks;
  the '1930'-then-rewrite disappears because the resolved account is known
  on the first render.
- TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates
  (and periods) from the hooks.
- BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create)
  invalidate the corresponding cache entries so every picker sees the
  change at once.
- fetchers.ts: booking templates are booking_templates rows
  (BookingTemplateLibrary), not the static BookingTemplate shape.

Per open: Bokför 5 requests -> 0 blocking (voucher preview is a
non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly
4 -> 0, Mall 2 -> 0, template pickers 1 -> 0.
raw-reference-fetch ratchet: 51 -> 46 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:37:28 +02:00
Jakob Wennberg ec9cab24cc feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951)
* feat(mcp): ask the bank first: connect link deep-starts the named bank's consent

The connect card used to open the generic picker page; the user then chose
the bank there. The agent now asks 'vilken bank har företaget?' among the
opening questions and passes it to gnubok_connect_bank, whose connect_url
becomes /import?mode=psd2&bank=<name>. BankSelector resolves the name
(exact, then unique prefix, then unique substring: ambiguous names fall
back to the prefilled picker rather than guessing an institution) and
auto-starts that bank's consent through the same onConnect handler, so
the duplicate-pending and renew-instead guards stay fully interactive.
The param is stripped via history.replaceState after the one-shot so an
aborted bank flow plus back-navigation does not silently relaunch.

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

* fix(mcp): first-year suggestion keeps the AB vs enskild firma distinction

Compliance-review finding on #1949: the suggestion text collapsed both
forms onto a 31 December end. Only an enskild firma's first year MUST end
31 December; an AB may pick any end within BFL 3 kap 3 §'s 18-month cap,
with 31 December as the common default. The lookup tool's still_to_ask
line and the skill now say so explicitly, and first-year-defaults
documents that fiscalYear-null is a strong-not-perfect filed-report
signal that must only ever feed confirm-question suggestions.

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 14:31:45 +02:00
Jakob Wennberg 9a56b7aff9 perf(bookkeeping): fiscal-year pickers and cash accounts read the session cache (#1934)
First consumer migration onto lib/reference-data. FyPicker and
FiscalYearSelector (14 consumer surfaces, 47 fiscal-period fetch sites
before this series) now read useFiscalPeriods(); with the layout seed the
restore of the persisted scope runs in the first effect tick and onReady
fires on mount instead of after a round trip. Their restore rules are
extracted into a pure resolveInitialFiscalScope() (lib/reference-data/
fiscal-scope.ts) so the two pickers cannot drift apart again, and the
restore runs once per company load, not on every background revalidation.

- /reports: the static catalog renders immediately; only the "no fiscal
  year" empty state waits for the picker (previously six skeleton bars
  until /api/bookkeeping/fiscal-periods resolved).
- JournalEntryList (/bookkeeping): resolves its initial scope from the
  cached list instead of its own fetch; the saved-scope shortcut still
  unblocks the entries fetch first when nothing is cached, and resolution
  is guarded to once per company so a revalidation can never snap a
  deep-link "all years" visit back to the stored year.
- /transactions: the account chooser reads useCashAccounts({ enabledOnly })
  (seeded) instead of fetching /api/cash-accounts on every visit; the bank
  sync button invalidates that entry after a sync.
- STORAGE_KEY_PREFIX / ALL_YEARS_VALUE move to a dependency-free
  fiscal-year-storage.ts (re-exported from FiscalYearSelector) so lib/ code
  can import them without a React component.

raw-reference-fetch ratchet: 55 -> 51 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:24:24 +02:00
Jakob Wennberg b436d47b64 feat(mcp): onboarding flow v2: migration-first SIE import in chat, one-confirm momentum, extended first-year heuristic (#1949)
Three changes from the first real E2E run (Arcim, 2026-08-26):

1. gnubok_sie_preflight: read-only scan of a SIE file shared in chat
   BEFORE anything is staged: parse, validate (per-verifikat balance, IB,
   closed-year P&L residual), CP1252-mojibake tripwire, duplicate
   file/period check, org-number match against the company (the
   wrong-company import is the worst silent failure this flow can have),
   and suggested account mappings shaped for direct passthrough to
   gnubok_import_sie. Both tools now also accept file_content_base64,
   decoded with the same encoding detection as the HTTP upload route so
   CP437 exports keep their åäö.

2. Onboarding skill v2: opens with TWO questions (orgnr + 'vilket system
   hade du innan?'), imports history before the bank (PSD2 rarely reaches
   far enough back), and a momentum rule: the create preview is the ONLY
   stop; connect tools are called without asking, and categorization
   starts as soon as the bank is active. connect_bank instructions now
   describe the account-selection dialog that actually gates the first
   sync, and the bank history cap.

3. deriveFirstYearDefaults: no closed fiscal period in the registry now
   extends the first-year window from 12 to 18 months (BFL 3 kap 3 §):
   a 13-month-old company with no annual report is still in its first,
   extended räkenskapsår (the Arcim case the 12-month rule missed).
   Applied in the web journey and the lookup tool.

tools/list ceiling 61.5K to 62K, documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:16:40 +02:00
Jakob Wennberg 52bfd7a399 perf(auth): skip the MFA factor lookup once the session is at AAL2 (#1933)
The enforced-MFA branch of the proxy called supabase.auth.mfa.listFactors()
on every page, RSC and prefetch request for every MFA-verified user with a
company. auth-js implements listFactors() as a getUser() network round
trip, so hosted page requests paid two Supabase Auth calls in sequence.

At AAL2 a verified factor exists by construction (the session got there by
verifying a challenge on one), so the lookup only runs on the aal1/aal1
path (users mid-enrolment), where it still gates exactly as before. The one
case deferred is a user who unenrols their last factor mid-session: the
JWT keeps aal2 until the next refresh, so the enrolment bounce lands on the
refresh instead of the next click.

Tests: aal1/aal1 still asks for the factor list and bounces to /mfa/enroll;
at aal2 listFactors is never called (even with a factor list that would
read as empty) on page, RSC and prefetch requests; the step-up bounce and
the no-company skip are unchanged.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:15:22 +02:00
Jakob Wennberg 47fe193c48 feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet (#1932)
* feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet

Customer report (2026-08-26): "it takes time before all fields load when
clicking around". The cause is on the client: fiscal periods, settings,
accounts, cash accounts, dimensions and templates are fetched raw from 47 /
27 / 14 / 8 / 12 / 5 independent call sites, uncached, on every mount and
every dialog open, each request paying the auth proxy and route wrapper
before its own query. SWR was adopted for exactly this on 2026-07-13 but
reached only three files.

This PR adds the layer; consumers migrate in the follow-ups.

- lib/reference-data/keys.ts: one key builder per data set, company id in
  position 1, null without a company; company_settings keeps the shape
  useCompanySettings already uses so that hook is seeded without a change.
- lib/reference-data/fetchers.ts: browser Supabase for fiscal periods and
  cash accounts (mirroring period.list and listForCompany ordering, pinned
  by tests), /api for the lists whose routes do real work (accounts RPC,
  dimensions ensure, template scoping, customer masking).
- lib/reference-data/hooks.ts: useFiscalPeriods, useCashAccounts,
  useAccounts, useDimensions, useBookingTemplates, useCustomers,
  useSuppliers, useArticles (+ re-exported useCompanySettings); one-minute
  dedupe, keepPreviousData, background revalidation kept on so writes from
  MCP/agents/other tabs surface.
- lib/reference-data/invalidate.ts: invalidateReferenceData(kind) for the
  success path of every client write.
- lib/reference-data/seed.ts + components/providers/ReferenceDataSeed.tsx:
  the dashboard layout fetches fiscal periods and cash accounts in its
  existing batch and hands them, with the settings row it already had, to
  SWR as fallback, so the first form of a session renders its period, bank
  account and settings-driven fields on first paint. getDashboardSettings
  now selects the full row for that (its other consumers read a subset).
  The chart of accounts is not seeded (hundreds of KB for large charts).
- scripts/checks/raw-reference-fetch.mjs, wired into check:guards as a
  per-file ratchet: GET-shaped fetch('/api/<reference path>') anywhere in
  client-facing code and .from('<reference table>').select( in 'use client'
  files. Baselined at 55 files; new sites fail CI; at 0 the entry goes.

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

* fix(checks): anchor every optional whitespace run in the raw-reference-fetch regex

CodeQL js/redos flagged the `\s*,?\s*\)` tail: two adjacent optional
whitespace runs around an optional comma backtrack polynomially on a long
near-miss. The URL and init-object pieces are now named fragments and
every whitespace run is followed by a literal, so there is one way to
match. Behaviour unchanged (same 7 fixtures + baseline count of 55);
a worst-case timing test pins the linear scan.

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

* fix(checks): make the use-client directive regex unambiguous (CodeQL js/redos)

An unclosed /* let the lazy comment body be re-split at every later /*.
The body is now (?:[^*]|\*(?!\/))* which cannot cross a */, so the outer
repetition has one parse. Pinned with a 3000-comment worst-case test.

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

* ci: re-trigger checks for the rebased head

No workflow ran for dd560a7af (nor after close/reopen); an empty commit
gives the pull_request event a fresh head. No code change.

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

* fix(checks): single-character whitespace alternative in the use-client detector (CodeQL js/redos)

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 14:14:54 +02:00
Jakob Wennberg a8980a3a41 perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited
for the slowest worklist scan, and the request also paid a sequential
bank_file_imports read after the batch, a second scan of the suggested
matches (getWorklistCounts counted the same 200 rows the pane listed) and
an awaited stale-dismissal delete on the read path.

- page.tsx awaits only what the greeting shell and the redirects need
  (settings, profile, agent profile, the Skatteverket flag); the notice
  line, the setup checklist and the Att göra + Fortsätt panes are async
  server components behind their own Suspense (hem-sections.tsx). RSC
  streaming applies to client navigations too, so the greeting paints
  first on every visit and each block fills in as its queries land.
- DashboardContent becomes the shell with three slots; HemNotices keeps
  the one client-side action (the wrong-account sign-out); HemSkeletons
  are the two fallbacks.
- getWorklistCounts accepts the suggested matches the caller is already
  fetching (a promise, so it stays parallel); listSuggestedMatches runs
  once at the scan cap and the pane shows the first five.
- countInboxDocuments runs its id chunks in one wave instead of N
  sequential round trips.
- getCompanyNotices takes deferReap; Hem passes Next's after() so the
  stale-dismissal delete runs after the response.
- bank_file_imports joins the checklist section's batch.

Tests: worklist aggregate (precomputed matches skip the rescan), notices
aggregate (deferReap receives the reap; the delete does not run inline
and runs when the task does).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:02:09 +02:00
Jakob Wennberg c31933b15b perf(api): write routes stop re-resolving the active company (#1928)
* perf(api): write routes stop re-resolving the active company

withRouteContext resolves the active company (one resolve_active_company
RPC, ~40 ms p50 on prod) and then, for the 256 routes that pass
requireWrite: true, called requireWritePermission(), which resolved it a
second time before its role select. Two sequential round trips repeating
work the wrapper had just done, on every mutating request.

requireWritePermission() and getCompanyRole() now accept an optional
`known` context; the wrapper passes { companyId }, so the helper goes
straight to the membership select. Callers that pass nothing behave
exactly as before, and the shared selectRole() keeps both helpers on the
same query. The role is still looked up, never trusted from the caller.

Tests: known companyId skips resolution, known role skips the select, a
known viewer is still 403, a known company without a membership row is
still 403, legacy calls unchanged; new lib/api/__tests__/with-route-
context.test.ts pins that the wrapper resolves the company exactly once,
hands it to the guard, never calls the guard on read routes, passes the
guard's 403 through with a request id, and emits Server-Timing.

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

* test(customers): viewer gate expects the wrapper to hand over the resolved company

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 13:55:48 +02:00
Jakob Wennberg b2e15bbd2a feat(perf): measure the auth proxy per request (Server-Timing + proxy completed log) (#1922)
The proxy in front of every page, RSC, prefetch and /api request makes
several sequential network calls (getUser, session state, the
resolve_active_company RPC, MFA factor lookups) and nothing measured them,
while the route wrapper has logged authMs/companyMs/handlerMs per API call
for months. This is the first PR of the responsiveness plan (customer
report: "it takes time before all fields load when clicking around"): the
baseline every later change is measured against.

- lib/supabase/proxy-timing.ts: pure helpers (request classification from
  the app-router headers, route template that collapses ids and tokens,
  Server-Timing formatting, a timed() accumulator).
- lib/supabase/middleware.ts: updateSession wraps updateSessionInner, times
  each phase, sets Server-Timing on page/RSC/prefetch responses and
  X-Proxy-Timing on /api responses (withRouteContext owns Server-Timing
  there), and emits one "proxy completed" log line per request.
- scripts/perf/log-percentiles.ts: p50/p90/p99 per group over
  `vercel logs --json` output, for both "op completed" and
  "proxy completed"; scripts/perf/README.md documents the protocol and
  targets.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:55:42 +02:00
Jakob Wennberg 188816652d docs(api,mcp): tool counts, changelog backfill, version-header honesty, lazy auth, endpoint map (#1929)
Brings the developer-facing API and MCP docs back in line with origin/main
(audit 2026-08-26). Docs only; no runtime behaviour changes.

- Tool counts: the server registers 153 tools; docs said 90+/100+/120.
  All now say "150+" (connect-claude, gnubok-mcp README, plugin README,
  mcp-server rules, CLAUDE.md, registry entry with refreshed updatedAt).
  Not derived from the tools array: lib/ must not import @/extensions/.
- REST changelog: backfilled the additive 2026-08 changes (#1909 report
  date ranges + PDFs, #1864 POST /companies, #1773 vat-declarations,
  #1405 PATCH settings, #1724/#1788 customer personal_number, #1809
  cash_account_id filter). API version date unchanged.
- Version headers: Gnubok-Deprecation is planned, not emitted; the
  Gnubok-Version request header is not read today (version.ts comment,
  versioning page, conventions overlay, regenerated skills/accounted-api).
- connect-claude Path A documents lazy auth (connector works before an
  account exists; sign-in on the first company-scoped call).
- MCP server README: real Anthropic SDK call sites, real resource URIs,
  pending-operations widget, public-tools/tasks/origin-guard/pii-guard.
  Rules file gains Lazy auth + feedback/tasks paragraphs.
- api-routes endpoint map regenerated from the filesystem (560 routes,
  55 families incl. v1, agent, reconciliation account-keyed, dimensions,
  peppol, rot-rut, webshop-orders, mileage, billing, skatteverket,
  receipt-hunt).
- gnubok-mcp/accounted-mcp: /settings?tab=api is the pre-redesign URL;
  now /settings/api (README + help hints, no version bump).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:54 +02:00
Jakob Wennberg b8605aabfc fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).

- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
  TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
  the client-side panel can bundle it. api-keys.ts re-exports everything,
  so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
  scope (reconciliation has three), shared by the panel and the OAuth
  consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
  hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
  derived from domain and scope id. The "(REST API)" heading suffix is
  computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
  counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
  scopes.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:42 +02:00
Jakob Wennberg 1185ab4294 fix(mcp): honest tool text and build-derived server version (#1923)
Tool text that lied to agents:
- gnubok_create_voucher pointed at gnubok_reverse_entry, which does not
  exist; the tool is gnubok_reverse_journal_entry. A scan of server.ts,
  skills/, prompts/ and structured-errors.ts found no other phantom names.
- gnubok_reverse_journal_entry said reversal_date defaults to today; the
  executor passes undefined and reverseEntry() uses the original entry
  date (same as the dashboard). Description now states that. No behaviour
  change.
- gnubok_get_vacation_balance promised an estimated semesterloneskuld in
  SEK but returned none. The tool now returns estimated_liability_sek
  using the same BFNAR 2016:10 day valuation as the year-close and the v1
  vacation-balance route (dayValueSek exported from semesterberedning),
  floored at zero for overdrawn balances. Descriptions trimmed so the
  tools/list payload stays under the 60.7K-token ceiling (60,696 after).
- gnubok_create_invoice said the invoice number is assigned at approval;
  it is assigned on send or mark-as-sent (ensureInvoiceNumber).
- gnubok_convert_invoice: "har redan makuleras" -> "har redan makulerats".
- lib/entitlements/keys.ts comment claimed bank_sync has no MCP tool while
  the map right below gates gnubok_connect_bank on it.

Version: MCP serverInfo.version, the extension version and /api/health all
hardcoded '1.0.0', so clients could not tell deploys apart. They now share
currentAppVersion() (commit SHA prefix inlined at build), resolved once at
module load so the definitions layer stays deterministic, with '1.0.0' as
the self-hosted fallback so Docker healthchecks keep a value. serverInfo is
not part of tools/list, so the catalog payload is unaffected by this part.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:15 +02:00