1676 Commits

Author SHA1 Message Date
Jakob Wennberg dd84d6c1bb fix(mcp): tag unmapped tool failures with their cause vocabulary (#2051) (#2135)
Closes #2051. errorCauseTag() shipped in #2027 written and tested but
wired to nothing. This connects it: the two execution catch paths (sync
call and task) now pass errorCause into mcp.tool_called, carrying the
SQLSTATE or coded-error code, else the error's class name, capped at 64
chars.

The rows this exists for are the UNKNOWN_ERROR residue, whose
errorMessage is the constant "Något gick fel. Försök igen." and whose
errorDetail is the English constant: 465 such rows in the last 30 days
(create_voucher 58 of its 60 failures, query_journal 122) with nothing
to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw
driver message can quote row values from a constraint violation and
belongs in the server log, never in event_log, so the raw message is
deliberately not captured.

A plain `new Error(...)` tags null rather than 'Error': tagging
everything is the same as tagging nothing. Pre-execution denials (scope,
capability, validation, unknown tool) pass nothing because their
errorCode already is the cause.

Self-tested by unwiring one call site and watching the new test name it.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:07:46 +02:00
Jakob Wennberg 191fb2cfce fix(audit): alias learning is not a rule change, stop logging it (BFNAR noise) (#2134)
Production falsified 20260901103000's exclusion list within 30 minutes
of deploy: 15 of the first 16 UPDATE audit rows on
categorization_templates changed only the learning columns plus
counterparty_aliases, because the learning path
(lib/bookkeeping/counterparty-templates.ts) merges new aliases in the
same write that bumps occurrence_count. Projected ~800 noise rows/day
against ~50/day of real rule changes, each one rendered into the
legally-facing behandlingshistorik as "Konteringsmall aendrad: Alias".

counterparty_aliases joins the trigger's strip list. The trade-off is
explicit: a human editing ONLY aliases is no longer logged. Accepted
because alias growth is overwhelmingly automatic, and a change that also
touches accounts, VAT, pattern or the active flag still logs: the first
real such row (2026-09-01 19:02:17Z, debit/credit/vat accounts changed
by the learning loop, BFN's automatkontering case exactly) was captured
correctly and stays captured under the new WHEN clause.

The pre-fix noise rows stay in audit_log (append-only). The read model
stops labelling the column, so alias-only diffs, historical ones
included, render as no-ops rather than rule changes; a diff that also
carries a real change shows only the real change.

pg-test extended: alias+learning update writes no audit row,
alias+account update still does. Read-model test pins the pre-fix noise
row shape to null.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:58:02 +02:00
Jakob Wennberg 53c9c0c4f9 feat(mcp): second examples batch: five more tools, seven examples, 80 tokens (#2137)
Continues #2100 under the margin the envelope trim (#2123) created:
58 999 to 59 079 against the 60 000 ceiling, spending under half the
room and leaving ~920.

Picked by evidence, not traffic alone: gnubok_search_tools was sent a
nonexistent `offset` in prod today, so its examples show the actual
levers (query, detail, limit) and the description of the mistake; the
create/complete document-upload pair's examples ARE the two-step flow,
same upload_id and file_name on both sides;
list_uncategorized_transactions is the highest-traffic read (15 122
calls/30d) and gets the pagination shape; link_document_to_voucher gets
the minimal linking call.

All seven pass the input-examples validation suite (same unknown-key
guard the server runs, plus required/type/enum/pattern and the
placeholder-id check), and the pinned tool list is updated.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:45:31 +02:00
Jakob Wennberg 8d9bf383d1 perf(mcp): trim the one schema 58 catalogued tools share, reclaiming 2 552 tokens (#2123)
The catalog had 116 tokens of headroom and server.ts took 70 commits in
the preceding 14 days, so the next ordinary tool addition would have
failed CI.

Measured before cutting, which is what made this findable: outputSchema
is 38% of the whole catalog (23 290 tokens), and STAGED_OPERATION_SCHEMA
alone accounts for 14 736 of it, the same envelope transmitted 58 times.
Descriptions, which the three previous rounds trimmed, are 10%.

Three edits to one constant, no tool demoted and no field removed:
period_status stops declaring its three sub-properties as JSON Schema and
carries them in one sentence (actor, approve and preview were already
bare objects, so this makes the envelope internally consistent), the next
hint drops args' redundant additionalProperties: true, and operation_id's
description loses six words.

Every change is in the looser direction on purpose. The server emits
structuredContent for every tool and the documented failure mode is a
declaration too TIGHT making a strict client reject a successful call; a
looser one cannot do that. next keeps additionalProperties: false because
staging.test.ts pins it as a closed shape, and a guard whose reason is
not in front of you is not a guard to loosen for 420 tokens.

Ceiling ratcheted 61 600 to 60 000, leaving ~1 070 tokens of deliberate
working margin rather than the ~300 the previous rounds left. The bench
log records the cycle that margin causes: ratchet tight, block the next
feature, bump, demote. If more is needed the lever is priced: the
envelope still costs ~11 800 tokens across those 58 tools, and the fix is
to stop repeating it, not to trim it further.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:30:37 +02:00
Mattsson b56da5d6c5 feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST

gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).

Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).

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

* fix(api): address skeptic findings on bank-connection freshness

- Map the bank-connections group into skills/accounted-api (apiskill:check
  crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
  a lapsed entitlement now answers with a capability error instead of
  status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
  connecting is normal, staleness threshold aligned to the UI's 36 hours,
  and re-authorisation is only advised for expired/error/consent-out, not
  for stale-but-active connections (lapsed subscription or deselected
  accounts are the usual causes there).

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

* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling

The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:54:55 +02:00
Jakob Wennberg a08bf51ced feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16)

Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR
2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record
"forandringar i bokforingssystemet som paverkar bokforingsposternas behandling
samt nar dessa forandringar infordes", and BFN's commentary names
behandlingsregler (automatkonteringar, fasta procentsatser) and new program
versions as the examples. Until now both changed without a trace.

Audit triggers on the behandlingsregler tables and the import logs:
mapping_rules, booking_template_library, categorization_templates,
salary_payroll_config, sie_imports, bank_file_imports. categorization_templates
learns on every booking (occurrence_count, confidence, last_seen_date), so
those telemetry-only updates are excluded by a WHEN clause the same way the
api_keys request counters are (20260721115701): only real rule changes are
logged. Measured against prod that is roughly 3 800 new audit rows a month
against an audit_log already taking 371 688, so about +1 %.

app_releases is an append-only log of program versions seen in production,
written by the runtime the first time a build answers a request. Vercel exposes
no build hook we can trust to write the row, so /api/version records it inside
after(): the handler returns synchronously and a floating promise could be
frozen before the insert lands, which is how a version log ends up silently
empty. The service client is constructed lazily so the constantly polled public
probe pays nothing once the module guard is set.

Program versions are rolled up per Swedish calendar day in the report. main
takes ~570 merges a month, so one event per version would be on the order of
7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the
~400 events a real company's year contains. The statutory unit is the date, and
the same sentence qualifies the requirement to changes that affect processing,
which a deploy list cannot distinguish anyway. app_releases keeps the
per-version truth for anyone who needs to go deeper.

AuditLogEntry.user_id becomes string | null. The column is nullable and
write_audit_log() falls back to auth.uid(), which is NULL for a service-role or
global write; the company-less salary_payroll_config rows are the first that
routinely hit it, and the read model already coded for it.

Also restores the point citations the 2026-07-27 pass removed while the chapter
was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified
against BFN's consolidated text.

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

* test(pg): fix two fixture bugs in the behandlingshistorik trigger tests

pg-real caught both, and neither is in the migration: the inserts fail
before the trigger is reached.

mapping_rules.rule_type is constrained to mcc_code / merchant_name /
description_pattern / amount_threshold / combined; the test used
'merchant'.

booking_template_library's btl_insert policy requires
current_user_can_write() and company_id = current_active_company_id(),
so the authenticated insert needs a company_members row and a
user_preferences.active_company_id, the same setup
booking-template-hidden.pg.test.ts uses.

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

* test(pg): assert the booking-template audit row inside the user transaction

withUserContext always rolls back, so the audit row the trigger writes
is gone before an outside connection can see it. The trigger fires in
the same transaction as the write, so the assertion belongs there too.
The other cases in this file write on the pool (autocommit) and are
unaffected.

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

* fix(reports): name every build id in the per-day program-version entry

Raised by the compliance review on #2097: the roll-up listed five ids
and a count, which leaves an auditor unable to reconstruct which
versions ran that day. app_releases keeps the full record, but the
report is the surface anyone actually reads. A day is bounded by the
deploy rate (~19), so the full list stays one readable cell.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:31:10 +02:00
Jakob Wennberg 169e7eaf4e feat(mcp): worked examples on five high-traffic tools, and in the error that rejects a call (#2100)
#2066 asked for input_examples on the top ~20 tools by call volume. The
binding constraint turned out to be budget, not writing: after #2089
reclaimed 3 763 tokens, its own policy required ratcheting the tools/list
ceiling down with it, so the real headroom was 317 tokens. Ten examples
across five tools cost 199, leaving ~118. The ceiling is not raised.

Tools picked from 30 days of mcp.tool_called crossed with the
combinations the descriptions already warn about and callers still get
wrong: account_override without an explicit vat_treatment (books gross,
no moms line), representation without deltagare and syfte, confirmed on
a high-risk approval, a balanced voucher where the moms leg is its own
line, and get_kpi_report, where one caller sent `metric` 604 times over
seven days to a tool whose only parameter is period_id.

Examples are also surfaced in the unknown-parameter error. That costs
nothing in tools/list, because it only ships on the response to a call
that already failed, and it reaches the caller that most needs it: a key
list told DueCue's agent which parameter was wrong but not what a
correct call looks like, and the same rejected call repeated for a week.

Every example is validated against its own schema by the same
findUnknownArgKeys guard the server runs, plus required/type/enum/pattern
checks. An example our own boundary would reject is worse than none: it
teaches the exact mistake the guard then punishes. That test caught three
invented enum values in this change's own first draft.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:18:19 +02:00
Jakob Wennberg e5fba9471e feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal.

1. Verifikationsserie is a closed dropdown, not a one-letter free-text
   field. Ships Fortnox's table verbatim from their Systemdokumentation
   (A Redovisning, B Kundfakturor, C Inbetalningar från kunder,
   D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa,
   G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön,
   L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not
   Kundfakturor: it is the general series manual entries land in, and
   migration 20260526120700 ships every source_type defaulting to 'A', so
   every existing company's A series already holds everything. Any letter
   the company already configured is appended so no existing value can
   fall out of the picker.

2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl.

3. Tabbing or clicking into an untouched amount proposes the outstanding
   difference, pre-selected so typing replaces it. Only fires when the row
   has an account, both amounts are empty, and the difference belongs on
   that side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
2026-09-01 20:05:36 +02:00
Mattsson 0406e628e1 fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them

The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").

Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.

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

* fix(invoices): gate issuance on the seller VAT number (skeptic finding)

The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).

Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.

Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
  periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.

The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.

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

* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths

Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.

Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).

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

* test(email): refresh brand-mail snapshots for the coherent VAT fixture

The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:30:25 +02:00
Jakob Wennberg b5da51ea0a feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide (#2105)
* feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide

The in-product MCP URLs omitted `tool_namespace=accounted`, and
resolveMcpToolNamespace() falls back to the legacy `gnubok_` prefix when the
param is absent. Every connection made from Settings therefore got `gnubok_*`
tool names while the docs, the accounted-api skill, and
claude-plugin/.mcp.json all reference `accounted_*`.

- Add `tool_namespace=accounted` to the Claude.ai, Claude Code, and
  Claude Desktop snippets.
- Move the Claude Desktop bridge from `npx gnubok-mcp` / `GNUBOK_API_KEY` to
  `npx -y accounted-mcp` / `ACCOUNTED_API_KEY`, and emit `ACCOUNTED_URL` so
  self-hosted and white-label instances get a config pointing at their own
  host. The `gnubok_sk_` key prefix is unchanged: it is wire format.
- Surface the Claude Code plugin, the only path that configures the
  connection and the seven workflow commands in one step.
- Rename the settings tab "API" to "API & MCP" and rewrite its intro: the
  MCP connection is what most users come here for, not API keys.
- Link the step-by-step guide from the panel, locale-aware.

Docs: add a Swedish /docs/api/anslut-claude alongside the English page (the
docs site has no locale routing, so each language is its own URL), give both
the Claude Code plugin path, and teach the export and freshness scripts about
the new page so cross-repo drift is caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): Cursor is not Claude Code; use the accounted_ tool name

Review follow-up on #2105.

`claude mcp add` is a Claude Code command. Cursor does not read it, so the
"Claude Code / Cursor" row and the docs sentence pointing Cursor users at that
command were both wrong (the row predates this PR; the docs sentence did not).
Cursor now gets its own row and its own `~/.cursor/mcp.json` snippet with the
`url` field, in the panel and in both docs pages.

Also `vat_close_check` -> `accounted_vat_close_check` in the reviewer test on
both pages, matching the identifier used in the prompts section above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* feat(settings,docs): one-click Connect to Claude, and cut the panel to one action

Anthropic documents an install link for custom connectors:
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=NAME&connectorUrl=ENCODED
(claude.com/docs/connectors/building/directory-vs-custom). It opens claude.ai
with the connector name and URL prefilled; the user still reviews and confirms,
and it grants nothing on its own. We were telling people to copy a URL and go
paste it somewhere else instead.

Settings panel, rendered and reviewed:
- "Connect to Claude" button is now the only thing above the fold. Everything
  that needs a config file or a terminal (claude.ai manual paste, Claude Code,
  the plugin, Cursor) moved into one "Other clients" disclosure, and the
  API-key methods keep theirs. Four code blocks -> one button, 1057px -> 719px.
- The connect group renders above the API-keys group. Connecting is why users
  open this tab; the tab's own intro says so.
- Each entry inside the disclosures shows its instruction as visible text.
  They were `?` HelpPopovers, so the panel read as opaque code blobs with no
  instructions on screen.
- Prose interpolates the brand's real casing, not the lowercased config key.

Docs, both languages: Path A leads with the install link and drops from five
manual steps to a link plus three short paragraphs, with the manual paste kept
under a subheading. No raw HTML: the docs renderer has no rehype-raw, so
<details> would have been silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): correct claude mcp add syntax and the SSR install link

Review follow-up. Both findings verified before acting on them.

`claude mcp add --help` gives `claude mcp add [options] <name> <commandOrUrl>`:
the URL is positional and there is no `--url` flag, so the API-key snippet
would have failed on a missing argument. Both commands now put
`--transport http` before the name and pass the URL positionally, in the panel
and in both docs pages.

The panel is server-rendered before it hydrates and window.location has no
server equivalent, so the install link was built from a relative mcpBase in the
first paint. A click in that window would hand claude.ai a connectorUrl it
cannot resolve. The origin now resolves after mount and the anchor carries no
href until it is known, which also makes it unclickable rather than wrong.
Verified: the SSR HTML contains no claude.ai href and no relative connectorUrl,
post-hydration the href is absolute, and there are no hydration warnings.

DECISIONS.md: code-span the `gnubok_*`/`accounted_*` wildcards so they stop
rendering as emphasis, and drop the "no one-click deeplink" claim from the
earlier entry rather than leave a false statement standing two lines above its
own correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:56:19 +02:00
Mattsson cd40127f0e feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)
* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API

The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.

- getAccountBalance now returns booked + available from the same
  quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
  balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
  plus bank_reported_* fields and fetch timestamp in the bank block;
  difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
  cash_today prompt now reports the bank's figure instead of teaching
  agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint

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

* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers

Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:

- external_balance stays null for the bank reconciliation kind: sign-off
  persists it into account_reconciliations and bokslutsbilagor computes
  closing - external from that row, so a today-balance stored on a
  balansdag sign-off printed a phantom warning-red differens in the
  year-end appendix. The bank-reported figure lives only in the
  timestamped bank_reported_* pair in the bank block, and only when its
  fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
  timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
  of fabricating amount 0 with a fresh timestamp; sync keeps the
  previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
  balance_updated_at, so an older sync run finishing later cannot move
  the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
  balances into cash_accounts too (accounts_data is deliberately not
  re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
  that only see the default catalog.

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

* fix(bank): express the stale-writer guard as two literal predicates for the schema guard

The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.

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

* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 16:16:29 +02:00
Mattsson a57a8d968b fix(webshop-orders): stop syncing failed WooCommerce orders, remove stale unpaid rows (#2119)
* fix(webshop-orders): stop importing failed WooCommerce orders, remove stale rows on failed transition

Failed checkouts carry no money event but imported as permanently
unbookable 'Ej betald' rows (user report). orderImports() now excludes
'failed' alongside 'trash', and a re-polled order that transitioned to
failed deletes its existing row via removeWebshopOrders(), which enforces
the freeze boundary app-side: frozen rows are never deleted, and a parent
with a frozen refund child is spared because parent_order_id cascades.
Removal failures hold the sync cursor like upsert failures do.

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

* fix(webshop-orders): make failed-order removal race-safe per skeptic findings

Repeat every guard on the DELETE statement itself, not only the candidate
select: a row booked/marked/invoiced between the two round trips must
survive (TOCTOU refutation). Never remove paid rows (orderRemoves gated on
!orderIsPaid plus is_paid=false on both statements): money moved at some
point, and paid parents are the only rows that can carry refund children,
which also closes the cascade race without a DB trigger. Spare
cross-marked rows (legacy_transaction_id): the order may be booked via the
retired transactions feed without any freeze column set.

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

* fix(webshop-orders): audit-log successful failed-order removals

Compliance swarm finding (ISO A.8.10): the hard delete logged only its
failure path. Every successful removal batch now logs companyId, deleted
row ids and the requested external_ids, the only deletion record for
pre-bokforing rows that carry no behandlingshistorik.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:55:36 +02:00
Mattsson fa69174aa0 fix(bank): never pre-check or mirror another company's accounts in the EB callback (#2116)
* fix(bank): never pre-check or mirror another company's accounts in the EB callback

At one-session banks (SEB) the PSU's single consent can cover accounts a
sibling company books. The OAuth callback stored whatever the session
returned into the active company: all pre-enabled, mirrored into its
cash_accounts, ledgers allocated from its chart: one 'Spara val' away
from booking another aktiebolag's transactions (user report F1,
2026-09-01).

The deliberate reuse path (findReusableSessions) already guards claimed
IBANs; the callback now runs the same check via
fetchCrossCompanyAccountContext:

- accounts claimed by another of the user's companies are stored
  disabled + flagged (claimed_by_company_*), skipped by the
  cash_accounts mirror, and the picker names the claiming company
- a 'Synkas ej' deselection made on any other connection row is carried
  onto fresh rows (the recurring came-back-pre-checked complaint, C2)
- lookup failure fails closed: new accounts stored deselected
- accounts the row itself already carried keep their own state, so a
  renewal can never switch a working feed off

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

* fix(bank): close the skeptic-found holes in the cross-company claim guard

Consolidated fixes from the three-skeptic review of PR #2116 (all three
refuted the first cut):

- Active-company standing state (enabled cash_accounts + enabled
  accounts on its live-ish connection rows) now outranks sibling claims
  company-wide, not row-wide: a bank-list renewal arrives on a FRESH row
  with no priors, and the old row-local check would have let a sibling
  claim switch a working feed off while supersede demoted its cash row.
- pending_selection rows no longer claim accounts or feed deselection
  memory: their flags are unconfirmed callback output (including this
  guard's own fail-closed writes), so an abandoned picker or a transient
  lookup error can no longer poison later connects.
- Guard-disabled accounts are never mirrored from the callback:
  upsertFromPsd2 with enabled:false for a new-to-row account could
  promote the seeded primary 1930 manual row and flip it to disabled
  under a foreign identity.
- The selection save skips ledger allocation and the cash_accounts
  mirror for disabled never-mirrored accounts, so 'no cash row, no 19xx
  slot burned' holds past the mandatory Spara val, and strips the
  claimed_by_*/deselected flags when the user deliberately enables an
  account.
- Deselection carry is no longer silent: deselected_elsewhere flag +
  picker note 'Tidigare bortvald'.
- Claim lookups paginate via fetchAllRows: the bare select's silent
  1000-row PostgREST cap failed open for exactly the multi-company
  consultants the guard exists for.

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

* fix(bank): claim-guard round 2: pending_selection claims asymmetrically, paged reads ordered

Skeptic re-verification of 25a339810 found two holes:

- Excluding pending_selection rows from claims reopened the
  attach-to-picker window: an attach-created row holds deliberately
  offered enabled accounts with no cash_accounts rows until its picker
  is saved, and a full-OAuth connect in another company inside that
  window could take the same physical account. Enabled accounts on
  pending_selection rows claim again; their disabled flags still stay
  out of the deselection memory (unconfirmed callback output, including
  the guard's own fail-closed writes).
- Both fetchAllRows claim queries now order('id'): unordered .range()
  pagination can silently skip rows at page boundaries, and a skipped
  row is a missed claim, failing open at exactly the 1000+-row scale
  the pagination was added for.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:24:10 +02:00
Jakob Wennberg b1f7116231 fix(observability): clear the dead session on the first bounce, stop filing client disconnects as exceptions, scope the Hem probe (#2114)
Three small defects found underneath error clusters that are themselves benign.

bounceToAuth returned a bare NextResponse.redirect and never copied the cookies
off supabaseResponse, so the Set-Cookie headers that clear a dead session were
thrown away on the first bounce and the browser replayed the dead refresh token
once more on /login. That doubled both the GoTrue 400s and the log volume. The
AuthApiError itself is left alone: it is correct session-expiry handling that
auth-js logs from inside node_modules, and getUser() returns it as a value.

"The destination stream closed early." is a client disconnecting mid-stream,
produced inside React's Flight server. Next's own isAbortError filter does not
recognise React's cancel error, so instrumentation.ts reported it to PostHog
Error Tracking as a real exception against real users' session replays and paid
an awaited flush on an otherwise-healthy request. A narrow predicate now
early-returns before PostHog is touched. This cannot remove the line from
Vercel's runtime-error table, which is fed by Next's stderr.

other-account-hint.ts issued an unfiltered journal_entries probe on the
blocking Hem render path, inside a render Promise.all: roughly 1 in 20 Hem
loads waited an extra 2.5 s for an advisory nudge. An unfiltered probe on a
multi-tenant table is also a correctness smell. It is now company-scoped and
off the blocking path.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:03:23 +02:00
Jakob Wennberg f1d76deaba fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)
* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura

Two independent defects in the provider migration, both customer-visible.

A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.

fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.

Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.

Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): entity line needs the failed flag

The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): write the missing-reference disclosure onto the credit note itself

Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.

The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:57:48 +02:00
Jakob Wennberg 1c04262e06 fix(byra): stop passing a lucide icon across the server boundary on the KPI empty state (#2110)
app/(dashboard)/byra/kpi/page.tsx is a Server Component that passed the lucide
icon TrendingUp as icon={TrendingUp} into EmptyState, a Client Component.
lucide builds every icon with forwardRef, so the value cannot be serialized
across the RSC boundary: the render throws and the route 500s into the
dashboard error boundary. The branch runs only when the byra team has zero
non-archived clients, which is every brand-new byra on day one, and retrying
never helps.

Adds an EmptyByraClients preset beside the existing ones. A reference to a
client component is serializable where the icon is not, so the icon, the copy
and the design treatment are unchanged.

The preset reads from the byra namespace under its own translator name rather
than t, because i18n/__tests__/message-keys.test.ts maps one variable name to
one namespace per file: reusing t would silently re-point every other preset's
key in this file at byra.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:51:03 +02:00
Jakob Wennberg 4ec2ff4b4d fix(documents): name the journal_entries/fiscal_periods relationship so supplier-invoice underlag can anchor (#2109)
Prod has three foreign keys between journal_entries and fiscal_periods, so
PostgREST answers PGRST201 to any embed of that pair that does not name the
relationship. pickAnchorEntry() destructured only data, so the error was
dropped and the helper returned null on every call since it shipped on
2026-07-27: supplier-invoice underlag has never once anchored in production.
Users see "Underlag saknas" on a verifikat that plainly shows the invoice PDF.

Names the constraint, matching the already-merged sibling fix in
lib/transactions/inbox-underlag.ts (6a40b3c0e), and handles the error instead
of dropping it.

Adds scripts/checks/ambiguous-embed.mjs to the ratchet guard, because neither
test layer can see this class: a mocked Supabase client never resolves a
relationship, and pg-real bypasses PostgREST entirely. The check derives the
ambiguous table pairs by parsing supabase/migrations, so a migration adding a
second foreign key between two tables arms the guard on the same commit; the
derived list reproduces prod's pg_constraint output exactly. It accepts both
PostgREST hint forms (constraint name and FK column name, both in use here) and
parses aliased embeds, which is the shape the real bug took.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:44:54 +02:00
Mattsson 43341aa55c feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes

Click one checkbox, shift-click another, and every row between them
takes the clicked row's new state, the way mail clients work. Turns a
20-row bulk selection into two clicks.

New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor
and applies the range over the rows as currently rendered, so it follows
filtering, sorting and paging rather than the underlying data order. A
shift-click with no valid anchor (first click, or the anchor filtered
away) degrades to a plain toggle. Select-all and clear reset the anchor.

Wired into the 8 selection surfaces: transaction inbox and skattekonto
inbox (separate ranges, since the two row types book through different
endpoints), journal entry list, invoices, supplier invoices, orders,
pending operations, invoice inbox workspace.

Radix' onCheckedChange carries no mouse event, so each row records
shiftKey from the click that precedes it; the checkbox cells get
select-none so shift-clicking does not smear a text selection.

The pure range rule is unit tested (10 cases: both directions, range
unselect, anchor invalidation, rendered-order independence).

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

* fix(ui): void the range anchor on an empty selection, keep placeholders out

Review findings from CodeRabbit and the skeptic pass, all in the new
range-selection feature:

- Clearing a selection left the anchor behind, so the next shift-click
  extended from a row the user could no longer see selected (click a row,
  press "Rensa markering", shift-click 30 rows down, get 30 rows). The
  explicit resetAnchor() calls only covered the clear paths that were
  wired by hand; several others (period change, filter change, post-bulk
  success, "Avmarkera") were not. An empty selection now counts as having
  no anchor, which covers every clear path including ones added later.
- The invoice inbox passed optimistic upload placeholders into visibleIds
  even though they render no checkbox. Safe today only because
  placeholders are always prepended; filtering them out makes the
  invariant local instead of depending on insert order elsewhere.
- pending: "Godkänn alla" pre-selects a non-empty set, so it resets the
  anchor explicitly.

Two existing tests used a fixture the UI cannot reach (an anchor with an
empty selection); they now start from the state a real anchor implies.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:37:25 +02:00
Jakob Wennberg 237bdd0366 perf(db): index the foreign keys that actually carry delete fan-out, and event_log by company (#2115)
Two of 311 performance advisories have a real mechanism. event_log (265k rows)
has no index containing company_id, so a company-scoped read falls back to a
primary-key scan with a filter. And journal_entries has 37 inbound foreign keys
with roughly 16 children lacking a usable index, so every deleted entry fires a
sequential scan per child: stripe_payouts has 0 rows and 77 445 seq scans,
supplier_invoices 2 153 rows and 725 816.

This is index hygiene, not a user-facing bug. In the measured 24 hours there
were zero 5xx across 250 838 gateway requests at p95 63 ms, and GET /api/events
had no traffic at all.

Roughly 14 indexes, not 173. Child tables under about 500 rows are skipped: a
one-page sequential scan beats an index probe and the planner ignores the index
anyway. Every candidate was checked against prod first, and any already covered
by an existing index whose partial predicate is implied was dropped, so this
adds no duplicate.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:29:47 +02:00
Jakob Wennberg 77becf3d65 fix(documents): record archive integrity checks in their own ledger so the nightly control advances again (#2108)
The 03:00 WORM verification cron stamped last_integrity_check_at on
document_attachments. enforce_period_lock_documents() fires on any UPDATE of a
row whose journal entry sits in a closed or locked period, without checking
whether the entry link actually changed, so a read-only integrity stamp was
rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the
rejected rows re-sorted to the head every night and the batch became
permanently 200/200 blocked. Both call sites discarded the update error, so
nothing logged and nothing alerted.

Prod state: 34 557 current-version documents, 24 083 never checked, last
successful stamp 2026-08-31 03:00, nightly successes already decayed to
single digits.

Migration 017's enforcement triggers are legally required and never-touch, so
this does not narrow the trigger. The verification outcome moves to its own
document_integrity_checks table and the cron stops writing document_attachments
altogether, which takes the trigger off the write path. The legacy column stays
in place. Failures are now counted, logged and reported in the route's summary:
the silence is why this went unnoticed for weeks.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:23:05 +02:00
Jakob Wennberg 2c46d69d21 fix(sandbox): complete the teardown for the four FK and guard blockers added since August (#2112)
cleanup_sandbox_user deletes supplier_invoices and journal_entries without
first clearing four blockers added or missed since it was last fixed:
supplier_payment_batch_items (RESTRICT FK, that table was created 2026-08-10),
fiscal_periods.opening_balance_entry_id (NO ACTION FK whose NULLing is itself
blocked by enforce_opening_balance_immutability), terminal webhook_deliveries
rows, and payment_match_log rows whose company_id IS NULL, which fail the
shared audit bypass because it requires a non-null company.

Prod: 9 stale sandbox users, oldest 2026-07-22, retried nightly and failing
forever, so anonymous demo tenants accumulate indefinitely.

The bypass stays scoped to sandbox teardown. The audit-log immutability and the
webhook terminal-delete guard are not weakened for normal tenants, and the pg
test asserts they still bite for a non-sandbox tenant.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:45 +02:00
Jakob Wennberg 088754d61a fix(processing-history): register the missing event types, and strip the PII two of them carry (#2111)
* fix(processing-history): register the missing event types, and strip the PII two of them carry

Ten event types are emitted by code but absent from processing_event_types, so
every append fails the foreign key. Appends are best-effort try/catch, so no
user request fails, but the internal audit trail is empty for ten kinds of
legally motivated act, including the BFL 5 kap 5 § rattelse record when a user
swaps a transaction's underlag (TransactionDocumentReplaced) and the SOC 2
revocation record (OAuthClientRevoked).

Order matters and is deliberate. Two invoice-inbox events, RateLimitedDropped
and AttachmentsTruncated, put the raw sender address and mail subject in their
payload. Registering those types first would start persisting that PII into an
append-only table whose UPDATE is trigger-blocked and which the archive's
erasure path excludes. The strip therefore ships in this same commit, ahead of
the migration.

Only the invoice-inbox emitter was edited. whatsapp-inbox shares the
RateLimitedDropped type name with a payload that carries no phone number.

Closes the class rather than the two logged instances: a TypeScript union makes
an unregistered literal a compile error, and the pg test asserts the database
catalog is a superset of the code's list, generated from the union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(processing-history): strip the inbound-mail PII in the database, not by deploy ordering

Review finding (superagent-security, P2): shipping the emitter fix and the
catalog migration in one commit is not the same as one instant. Migrations
apply on merge while the replacement build takes minutes, so an old instance
can still write a sender address and mail subject in that window, and such a
row is permanent: processing_history takes no UPDATE and no DELETE, and the
archive export excludes it from the erasure path.

Adds a BEFORE INSERT trigger stripping `from` and `subject` from the
RateLimitedDropped and AttachmentsTruncated payloads, and keeps it afterwards
so the invariant belongs to the table rather than to one emitter's good
behaviour. The jsonb object check is load bearing: `payload - 'key'` raises on
a jsonb array and payload's shape is not constrained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:09 +02:00
Jakob Wennberg 21e6e2d314 fix(proxy): assert the production Supabase project for customer hosts instead of allowlisting them (#2107)
The white-label guard only fired for hostnames hand-listed in
CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. improveone.accounted.se was never added,
so when the .accounted.se domains were pinned to a feature-branch preview the
guard let it through: a customer-branded login page served from a build that
inlines the staging Supabase project, on the open internet, with no alert. The
26 August willem.accounted.se 503s were the same misrouting caught correctly,
because willem was on the list.

Inverts the model. Any customer-facing production hostname (not a *.vercel.app
preview, not localhost) must be served by the production Supabase project or
the guard trips. Adding a new white-label host no longer requires editing a
list in order to be protected.

Also closes two fail-open holes found alongside it. parseBackendHostname
returned null for an undefined NEXT_PUBLIC_SUPABASE_URL, so a missing project
read as "not staging" and fell through; it now trips the guard. And proxy.ts
gains an explicit env guard: today lib/supabase/middleware.ts asserts the URL
and key non-null and @supabase/ssr throws synchronously as the first statement
of updateSessionInner, which takes down every path including /login and
/robots.txt with an opaque crash rather than a deliberate 503.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:07:34 +02:00
Jakob Wennberg 57d757192e fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs (#2106)
* fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs

Supabase's bootstrap ALTER DEFAULT PRIVILEGES grants EXECUTE on every
public-schema function to PUBLIC, anon, authenticated and service_role, and
PostgREST publishes each one at /rest/v1/rpc/<name>. Seven SECURITY DEFINER
functions were therefore unauthenticated cross-tenant primitives that bypass
RLS for anyone holding the public anon key:

  sync_team_to_company          INSERTs into company_members, the table
                                user_company_ids() and every RLS policy read
  claim_due_webhook_deliveries  returns every tenant's webhook payloads
  generate_delivery_note_number UPDATEs company_settings, needs only a company id
  generate_article_number       UPDATEs company_settings and articles
  generate_invoice_number       UPDATEs company_settings and invoices
  peek_next_invoice_number      leaks another tenant's prefix and next number
  get_next_arrival_number       leaks another tenant's ankomstnummer series

The last three carried a guard, and it did not hold. Its shape is
"IF auth.uid() IS NOT NULL AND NOT EXISTS (membership) THEN RAISE", with a
comment explaining that a NULL auth.uid() means service role or cron and is
trusted. The anon key's JWT carries no sub claim, so auth.uid() is NULL for
role anon as well, and the guard short-circuits straight into the trusted
branch.

Revokes EXECUTE from PUBLIC and anon on all seven, and from authenticated on
the two with no user-session caller. PUBLIC is mandatory: proacl carries
"=X/postgres", so revoking from anon alone leaves has_function_privilege true.
The five numbering RPCs the app calls on the user's session client are
re-granted to authenticated only after their guard was replaced with a
fail-closed one. A sweep then revokes PUBLIC and anon from every remaining
definer writer in public; all 20 carry explicit authenticated and service_role
grants, verified against prod, so no signed-in or service path changes.

tests/pg/definer-function-grants.pg.test.ts generates its assertion from that
same sweep rather than a hand list, because hand-listing is exactly how the
three guarded numbering RPCs came to be declared safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(security): keep procedures out of the definer-writer sweep

Review finding (CodeRabbit, Major): the sweep predicates excluded only trigger
return types, so a SECURITY DEFINER PROCEDURE would match and the migration
would then run REVOKE ... ON FUNCTION against it, which Postgres rejects,
aborting the whole migration. The pg test's offender query had the same gap and
would have reported a procedure the migration could not fix.

public holds no procedures today (verified against prod 2026-09-01), so this is
a guard against the first one anyone adds rather than a live bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:00:36 +02:00
Mattsson 08b1119c7d feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2) (#2103)
* feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2)

In connector mode (GNUBOK_CONNECTOR_KEY set, no own SKV credentials) the
Skatteverket extension now routes through the hosted connector stack
(#1757) instead of calling Skatteverket directly:

- skvRequestWithAuth routes to the data proxy: base URL maps to a service
  segment (moms/skattekonto/agd-inlamning/agd-period), the user's SKV
  Bearer moves to X-Connector-Upstream-Authorization, the connector key
  authenticates the proxy, and the gateway Client_Id/Client_Secret are
  omitted (the proxy adds Arcim's). Connector-layer 4xx bodies (code
  CONNECTOR_*) are classified before the SKV-shaped 401/403 sniffing so a
  broker refusal surfaces operator guidance (check GNUBOK_CONNECTOR_KEY),
  never APIGW/BankID guidance for knobs the instance does not have.
- OAuth: /authorize starts the consent via the broker's authorize-url
  (persisting its redirect_uri + connector_state), the hosted SKV callback
  bounces the code back to the instance, and exchangeCodeForTokens /
  refreshAccessToken exchange through the broker's /oauth/token,
  unwrapping its { data } envelope. Tokens still rest encrypted on the
  instance; client_id/client_secret never exist there.
- Broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED
  (terminal; reconnect fixes); broker 502 stays a raw error so a transient
  SKV outage never re-arms the reconnect banner (#1155).
- getSkatteverketEnvironment() reports 'prod' in connector mode: the
  upstream env is hosted's, and the instance's unset defaults would show a
  false Testmiljo badge on real filings.
- System (CCG/ombud) auth is deliberately not brokered: hosted-only,
  stays direct.

Hosted and own-credentials self-hosts are byte-identical: every branch
gates on skatteverketConnectorMode(), which is null whenever own SKV
credentials exist or no connector key is set. Direct-path tests pin that.

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

* fix(connect): classify SKV dead-refresh-token dialects broker-side; forward diagnostic headers; connector-aware gateway guidance

Skeptic refutation on PR #2103 (found independently by the correctness and
compliance skeptics): the broker's /oauth/token catch-all collapsed SKV's
terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant,
"Refresh Token status is expired": the dominant refresh outcome, per-flow
tokens live 65 minutes) into the generic 502 CONNECTOR_SKV_TOKEN_FAILED, so
a connector instance could never classify ordinary session expiry: raw
English 500s instead of the reconnect flow, staged filing operations
consumed as non-recoverable, crons retrying raw forever.

- Broker /oauth/token: re-codes those dialects as 401
  CONNECTOR_SKV_REFRESH_DEAD, refresh grant only (invalid_grant on the code
  exchange means an expired one-shot code and keeps the generic 502). The
  classifier (isSkvDeadRefreshTokenError) uses the same regex set the
  extension's direct path classifies with.
- Instance dead-token classifier maps CONNECTOR_SKV_REFRESH_DEAD to
  SESSION_EXPIRED alongside 404 CONNECTOR_NOT_OWNED; the generic 502 stays
  a raw error so a transient SKV outage never re-arms the reconnect banner.
- Data proxy: forwards WWW-Authenticate and x-skv-*/x-amzn-*/x-api-*
  response headers (the instance's MISSING_SCOPE classification reads them;
  body-less gateway rejections carry no other signal).
- Instance gateway-refusal guidance is connector-aware: a self-host has no
  SKATTEVERKET_APIGW_CLIENT_ID and no Utvecklarportalen access, so connector
  mode points at /api/connector/status and support instead.

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

* fix(connect): reject redirects on the instance's broker OAuth requests

CodeRabbit inline finding (CWE-200): the connector-mode authorize-url and
token requests followed redirects by default, so a 307/308 would resend the
connector key (and code/refresh token) to the redirect target. redirect
'error', matching the broker's own postToken rule; the token response must
only ever come from the broker endpoint itself.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 12:11:34 +02:00
Mattsson ee22c9c7b7 feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) (#2104)
* feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3)

Self-host only: shows per-upstream connector mode, key prefix, and the
active company's granted capabilities from GET /api/connector/status,
plus a manual run of the entitlement sync via the new authed
POST /api/connector/sync (requireWrite, 60s cooldown) instead of
waiting for the hourly cron. Hidden on hosted.

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

* fix(connect): handle sync fetch rejection, count capabilities not rows, not_configured toast

Skeptic findings on the Synka nu flow: a rejected fetch (instance
restarting) was a silent no-op with an unhandled rejection; the success
toast printed grant rows (companies x scopes) as capabilities; a
not_configured outcome claimed the hosted service was unreachable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:44:33 +02:00
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy

Multiple people in one company becomes a paid capability (multi_user, the
eighth PAID key). Derived at access time from capability_grants, no status
column, no enforcement cron:

- entitled: active grant (trial/stripe/team/manual/comp), everyone works
- grace: newest grant expired < 20 days ago; countdown banner for everyone
  in companies with > 1 user; invites still allowed
- frozen: only role=owner resolves; other memberships go dormant (rows
  untouched, paying reactivates instantly); invites 403 with paid-plan upsell

Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin
untouched: they also run on self-hosts, where the gate never bites), gated
query fallback for service-role/API-key paths, setActiveCompany guard, MCP
company-access check, invite route. Middleware routes all-frozen users to a
new /paused page; the switcher greys locked companies.

Migration 20260901081417 (applied to staging): trial trigger seeds
multi_user, backfills for mid-trial companies, active Stripe subs, team
agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20
days) for existing unpaid multi-member companies. Daily cron mails owners at
grace start and last day. Strings in sv+en; pg-real + unit tests included.

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

* fix(billing): multi-user seat gate hardening from skeptic review

- Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting
  it: the 20-day grace window hangs on an expired row, so a deleted one
  froze churned payers' staff instantly with no banner and no mail. Other
  stripe grants keep the freeze-and-retain delete.
- New SECURITY DEFINER company_multi_user_state() RPC (migration
  20260901083726, applied to staging) and RPC-first getMultiUserState:
  capability_grants RLS hides team-scoped rows from non-team users, so
  user-client reads misread byra-covered companies as frozen (switch
  refusal, wrong switcher locks).
- Byra-kind teams get a standing team-scoped multi_user grant (backfill +
  teams trigger): byra client companies have no company-scoped trial by
  design, so a grantless byra team would freeze every consultant and
  client user.
- Comped/manual companies with active PAID-key grants extend to multi_user
  (a comped company must not read as paying while locking out user two).
- /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403).
- PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user
  rows; the gated fallback would have frozen every non-owner mid-deploy).
- Grace cron: covers team-scoped lapses (byra agreement ending) and skips
  the start mail for the hand-mailed grandfather cohort.
- Tests updated/added across all touched surfaces; pg tests for the new
  RPC and byra trigger; trial-suppression pg test extended to 8 keys.

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

* fix(billing): decouple seat-gate env check and fail open on gate read throws

CI round 1 on #2099:
- isMultiUserEnforced no longer imports has-capability: several route test
  suites partially mock that module and the vitest mock guard threw from
  inside the v1 seat gate, turning expected 4xx responses into 500s.
  multi_user is never a connector capability, so the bypass reduces to the
  same env reads, now inlined.
- getMultiUserState wraps its resolution in a fail-open try/catch: a client
  without .rpc or a thrown network error must never lock users out.
- no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or()
  scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's
  timestamp .or(); all columns in both strings are literals.

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

* fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3)

company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and
were granted to authenticated with a caller-supplied company UUID: any
logged-in user could probe an arbitrary company's billing state and grace
deadline across tenants. Migration 20260901091752 (applied to staging)
requires an auth.uid() membership in the target company when a JWT is
present, keeps service-role/definer contexts unrestricted, and clamps the
grace window to [0, 20] days. pg tests: stranger gets false/NULL, member
reads normally, oversized p_grace_days cannot widen the probe.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:29:12 +02:00
Mattsson ca12b1855e fix(connect): PR #1758 CodeRabbit follow-up: connector status hardening, i18n strings, doc alignment (#2098)
* fix(connect): PR #1758 CodeRabbit follow-up: harden connector status, i18n the connector-mode strings, align docs

- getConnectorConfig() rebuilds baseUrl as origin + path: userinfo, query
  and fragment are stripped (warn-logged without the raw value) so nothing
  secret-shaped pasted into GNUBOK_CONNECT_URL survives into the
  /api/connector/status echo or the derived proxy URLs (CWE-200)
- /api/connector/status responds Cache-Control: no-store on both branches
  (key prefix + wiring layout out of shared browser caches, CWE-525)
- CWE-319 thread verified as no-change: both connector-mode helpers derive
  from getConnectorConfig(), which fails closed on non-https
- SkatteverketConnectPanel tooltips and BankSyncNowButton gate/upsell
  strings moved to messages/sv.json + messages/en.json keys
- DECISIONS.md: MD037 fix on line 1146 (backtick the glob), line 1147
  reworded to grants-written-wiring-pending, decision lines appended
  (incl. declining the UpgradeNote children-append suggestion)
- docs/SOVEREIGN.md availability wording aligned with SELF-HOSTING.md:
  infra merged, keys issued manually on request, client wiring pending

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

* docs(connect): skeptic follow-up: bank client wiring is merged (#2094), SKV pending, no keys issued until it lands

Skeptic refutation on PR #2098: SOVEREIGN.md claimed the services 'do not
carry traffic' while this branch already contains #2094 (EB client proxy
routing), and 'issued manually on request' contradicted the standing
no-key-before-full-PR6b rule while skatteverketConnectorMode() has no
client consumer yet. SOVEREIGN.md, SELF-HOSTING.md and DECISIONS.md line
1147 now all say: bank client wiring merged and carries traffic with a
key, Skatteverket client wiring ships in a following release, keys are
not issued until it lands.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:16:37 +02:00
Mattsson b74b5e3c0d fix(company): revoke store connections when archiving a company (#2096)
* fix(company): revoke store connections when archiving a company

Archiving a company left its WooCommerce/Shopify/Stripe connection rows
at status 'active'. The store-uniqueness partial indexes (one active
company per store) then blocked reconnecting the same store from any new
company with 'Butiken är redan ansluten till ett företag', and since
user_company_ids() hides archived companies there was no user-reachable
disconnect. The archive flow now flips pending/active connections to
'revoked' and nulls their secrets, mirroring the manual disconnect paths.

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

* fix(company): audit connection revocations and cover error path on archive

Review findings (compliance swarm A.8.15/A.8.29 + skeptic pass):
- write audit_log rows for each revoked store connection (the tables
  have no auto-audit trigger)
- revoke via .neq('status','revoked') so 'error'-state rows, which can
  also carry credentials, are cleared too
- test that the archive still succeeds when a connection revoke fails

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

* fix(company): literal payloads for connection revokes (phantom-column ceiling)

The spread/mapped payloads registered as unresolvable expressions in
tests/schema/no-phantom-columns.test.ts (392 > ceiling 391). Inline each
table's update as an object literal and insert audit rows one per row.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:12:12 +02:00
Mattsson 5f81c0638c fix(ui): make row selection checkboxes always visible at muted opacity (#2093)
* fix(ui): make row selection checkboxes always visible at muted opacity

User feedback: bulk-select checkboxes hidden until hover are "sjukt
pilligt": an invisible 16px target forces a precise hover-then-aim per
row, and bulk selection is a primary workflow on list pages.

New CHECKBOX_REVEAL_CLASS in dry-table.tsx: rest at opacity-50, solid on
row hover, focus, coarse pointers, and checked state. Applied to the 8
selection-checkbox sites (transaction inbox, skattekonto inbox, journal
list, invoices, supplier invoices, orders, pending, invoice inbox
workspace). Also fixes a touch bug: TransactionInboxCard and
SkattekontoInboxCard lacked pointer-coarse fallback, leaving unselected
checkboxes permanently invisible on touch devices.

Row action controls (chevrons, quiet links) keep HOVER_REVEAL_CLASS.

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

* fix(ui): meet WCAG 3:1 on resting selection checkboxes via border-foreground

Skeptic finding: the checkbox primitive's border-input is ~1.4:1 against
the page, so the new opacity-50 resting state composited to ~1.2:1,
under the 3:1 non-text contrast minimum design.md commits to. Putting
border-foreground unconditionally on the 8 reveal checkboxes lands the
resting border at >=3.4:1 in every theme (light 3.41:1, dark 4.24:1,
white-label palettes similar) and the hover/solid state at ~17:1.

The border class lives at call sites, not in CHECKBOX_REVEAL_CLASS,
because the constant is sometimes applied to a borderless wrapper
(InvoiceInboxWorkspace); documented in dry-table.tsx.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:39:34 +02:00
Mattsson 05dce83a2b feat(connect): Enable Banking client routes through the hosted proxy in connector mode (PR6b-1) (#2094)
* feat(connect): route the Enable Banking client through the hosted proxy in connector mode

PR6b-1 of the instance-side client wiring. Until now bankConnectorMode()
had no consumer but the status label; this makes a self-host with a
connector key and no own EB credentials actually reach Enable Banking
through the hosted bank proxy.

- api-client authenticatedFetch: in connector mode swap the base URL to
  the proxy and send the connector key as a Bearer token. The EB JWT
  signer (getAuthorizationHeader) is never called: the instance holds no
  private key. On hosted and on own-credentials self-hosts the direct
  path is byte-identical.
- startAuthorization forwards X-Connector-Company so the proxy can meter
  the per-company connection quota; index.ts passes companyId at both
  connect sites.
- createSession forwards the signed connector_state so the proxy binds
  the /sessions exchange to the pending ledger row (single-use, race-safe).
- callback route reads connector_state from the query (echoed by the
  hosted callback) and threads it through finalizeConnection.

Tests: connector-mode base/auth/company-header/connector_state assertions
in api-client, direct-path and own-credentials byte-identity, and the
callback threading both connector and direct paths.

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

* fix(connect): gate X-Connector-Company on connector mode, not on companyId

Skeptic regression finding: index.ts passes companyId to startAuthorization
unconditionally, and the header was attached whenever companyId was truthy.
On hosted and on own-credentials self-hosts companyId is always set, so every
direct POST /auth to the real Enable Banking API carried the tenant's internal
company UUID: a needless behavior change on the production path and an
identifier leak to a third-party PSD2 processor (the "byte-identical direct
path" claim was false).

Gate the header on bankConnectorMode() so it is sent only when the request
actually goes to the hosted proxy. Adds a direct-path test asserting the header
is absent even when companyId is passed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:26:40 +02:00
Mattsson e113e9c099 fix(vat,documents): EU reverse-charge packs feed ruta 20/21; daily reanchor cron for floating supplier-invoice underlag (#2095)
Two user reports (Anders, 2026-08-25 + 2026-08-29):

1. The seeded standardmallar "Inkop EU-varor/-tjanster, omvand moms 25%"
   booked the cost on 4010/6540, which no momsdeklaration ruta reads, so the
   fiktiv moms filled ruta 30/48 while ruta 20/21 (inkopsvarde) stayed 0;
   Skatteverket rejects that (FK004, ML 13 kap). The packs now book directly
   on the basis accounts 4515/4535 (ACCOUNT_RUTA -> ruta 20/21); the
   transaction-picker path already skips its own basis emission for basis
   debit accounts, so no double counting. Regression test pins every
   reverse-charge pack to a 44xx/45xx business debit. Prod rows update via
   the existing pack sync cron (upsert on pack_slug).

2. A kontantmetod payment verifikat stayed "Underlag saknas" although the
   invoice PDF was attached and eligible on every static condition: the
   inline anchorSupplierInvoiceDocument silently did nothing (prod case
   2026-08-28, verified in audit_log: no document_attachments update between
   the payment booking and the user's manual re-upload). The helper now
   verifies the guarded update actually matched a row instead of claiming
   success on zero rows, logs its silent bail branches, and a new daily cron
   (/api/documents/reanchor/cron) re-runs the anchor for any floating
   retained document with a posted verifikat, replacing the pattern of
   one-off repair migrations (20260727180000, 20260824150000). The sweep
   names the FK in its embed and is idempotent; locked/closed periods are
   skipped as before.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:16:07 +02:00
Mattsson 08dbfdc5c4 fix(kpi): reserve label headroom in monthly result bars so the endpoint value never clips (#2092)
The Resultat per manad pane scaled positive bars to 55% of the chart
height with a fixed 62%-height baseline whenever any month was negative,
leaving ~8px above the tallest bar: the value label drawn 5px above it
rendered half outside the viewBox. A dominant negative month overflowed
the bottom edge the same way.

Replace the fixed baseline with one px-per-krona scale for both signs
and place the baseline from the actual positive/negative maxima, with
16px reserved on any side that can carry the endpoint label.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:52:54 +02:00
Mattsson a47d54f1d7 feat(supplier-invoices): sortable list columns matching the customer invoice list (#2091)
Adds client-side sorting to /supplier-invoices: all seven columns get the
same accessible SortableHeader as /invoices, backed by a pure comparator
(Swedish collation, nulls last, displayed-value semantics, stable
invoice_date/id tie-break). The click cycle is tri-state (asc, desc, back
to the API default of due date ascending) per the verifikat-list
precedent, since this list has a meaningful default order to return to.
The detail-pager list context follows the sorted order.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:23:31 +02:00
Jakob Wennberg 50f13cf198 feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint (#1758)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

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

* feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget

Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A
self-hosted instance with a `bank_sync`-scoped connector key can now connect
a bank through Arcim's PSD2 credentials; the bank session id and all
transaction data stay in the instance's own database (founder decision:
tokens on the instance, proxy stateless).

- Migration 20260820124000: `connector_connections` (secret-free ledger:
  sha256 of the EB session id + account uids, service-role only),
  `connector_upstream_counters` + RPC `connector_reserve_upstream` (global
  budget under EB Annex 1 §5's 300/min, shared with hosted), and
  `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs
  REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real
  covers all of it.
- EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core
  must not import @/extensions/); the extension re-exports it, tests
  unchanged.
- lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed
  connector state (15-min TTL) so the consent redirect can use OUR
  registered EB callback and bounce back to the instance, no per-instance
  redirect URI at EB; the callback route gains that connector branch.
- app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions,
  accounts/{uid}/{balances,transactions}), never open passthrough. POST
  /auth enforces the per-company connection quota + rewrites redirect/state;
  reads/deletes verify ledger ownership; every upstream call takes the
  global budget (429 + Retry-After when exhausted).
- issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of
  v1), --bank/skv-connections-per-company + --sync-min-interval.
- Docs (SELF-HOSTING: bank connector live), DECISIONS.

Verified: 52 connect unit tests + 13 pg-real (run locally against
supabase/postgres with all migrations) + EB extension suite (225, jwt
relocation intact); full unit suite 15 979 green; tsc, guards, lint clean.
Not in this PR: SKV broker (PR5b) and instance wiring (PR6).

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

* feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance)

Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted
instance with a `skatteverket`-scoped connector key can now run the BankID
consent, file VAT/AGI and sync skattekonto through Arcim's registered
Skatteverket client; the SKV tokens are returned to the instance and stored
(encrypted) there.

- lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data
  helpers (authorize URL, code/refresh exchange with Arcim's client secret,
  the four backing-API base URLs, the API-gateway Client_Id/Client_Secret
  headers). Core can't import @/extensions/, so this duplicates the
  extension's endpoints/scope set (one integrator = Arcim), mirroring the EB
  JWT relocation.
- app/api/connect/skv/oauth/authorize-url: builds the authorize URL against
  OUR registered redirect_uri + a signed connector state, per-company SKV
  connection quota, pending ledger row.
- app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens
  to the instance; the ledger keeps only sha256(access_token) +
  sha256(refresh_token).
- app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto /
  agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as
  X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the
  token hash against the ledger, adds Arcim's gateway credentials (never
  exposed to the instance), forwards. Same per-key + global budget as bank.
- The Skatteverket extension /callback gains the connector branch
  (isConnectorState -> 302 back to the instance; code never exchanged there).
- Docs (SELF-HOSTING: SKV connector live) + DECISIONS.

Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector
branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean;
no-phantom-columns held at 380 (literal update branches). Not in this PR:
instance-side wiring (PR6).

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

* feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint

Sovereign plan WS3 PR6 (enablement layer), stacked on the SKV broker (#1757).

- docker/extensions.self-hosted.json += enable-banking, skatteverket: a
  connector-key self-host now ships the bank + Skatteverket extensions; a key
  with the matching scope makes them work, without one they show the existing
  capability_blocked upsell (unconfigured extensions no-op).
- lib/connect/instance/upstreams.ts: the connector-mode seam. An upstream is
  in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has
  no own credentials for it (hasOwnEnableBankingCredentials /
  hasOwnSkatteverketCredentials). Hosted always has own credentials, so hosted
  is provably never in connector mode: the guard is what keeps hosted
  byte-identical. Base URLs GNUBOK_CONNECT_URL/api/connect/{bank,skv}, headers
  X-Connector-Company / X-Connector-Upstream-Authorization.
- GET /api/connector/status: the operator's wiring view (self_hosted, per
  upstream own_credentials|connector|unconfigured, key prefix never the key,
  granted connector capabilities). Hosted returns self_hosted:false.
- Docs (SELF-HOSTING: status endpoint + extensions ship in the image),
  DECISIONS.

Tests: connector-mode detection matrix (off without a key, off with own
creds incl. the _PRODUCTION EB variants, on via the proxy, CONNECT_URL
override) + status route (self-host vs hosted, unconfigured, per-upstream
mode, prefix-not-key). 83 connect/connector tests green; tsc, guards, lint.

DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify
end to end, touches the live consent path): wiring the EB api-client /
consent callback and the SKV oauth / api-client to call the proxy in
connector mode, and the "Synka nu" settings row (UI, needs visual sign-off).
The seam + preset + status route make PR6b a contained follow-up.

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

* refactor(connect): upstreams seam reuses lib/entitlements/own-credentials

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

* test(connector): status route tests pass the Next params argument (post-merge withRouteContext signature)

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

* docs(self-host): collapse the re-duplicated connector section; correct the crontab generator's preset comment

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

* fix(self-host): UpgradeNote and SKV tooltip name the connector key, never the hosted subscription; SOVEREIGN.md updated to merged reality

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

* fix(self-host): BankSyncNowButton gate copy branches like UpgradeNote (connector key, not hosted billing)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-31 23:18:43 +02:00
Jakob Wennberg b643e6ce64 perf(mcp): demote ten unused catalog reads, reclaiming 3 763 tokens of budget (#2089)
* perf(mcp): demote ten unused catalog reads, reclaiming 3 763 tokens of budget

The tools/list payload bench sat about 20 tokens under its 65 000 ceiling, so
the next tool or field anyone added failed CI. That is not hypothetical: it
already cost the agent-briefing field in #2079, which was built and then
removed for want of roughly 85 tokens.

The bench's own comments deferred the fix twice, in the same words both times:
picking which read to demote needs prod usage data, not a guess inside an
unrelated PR, so do the demotion as its own change and ratchet the ceiling back
down. This is that change.

Selection is from 60 days of mcp.tool_called: default-catalog READ tools with
25 or fewer calls, excluding anything named in RECOMMENDED_WORKFLOW_LOADOUTS.
Ten qualified. Measured 65 046 -> 61 283, ceiling ratcheted to 61 600.

Usage data is necessary but not sufficient, and three classes were kept in the
catalog despite low counts:

- gnubok_call_tool, which IS the search-only bridge.
- the connect_* onboarding tools and gnubok_lookup_company. A fresh agent has
  not learned to search yet, which is exactly why #1936 put them in the catalog.
- every arsredovisning, dispositioner, depreciation and accrual PROPOSAL tool.
  A 60-day window ending in August cannot see bokslut season: most Swedish
  companies close on 31 December and do the work January to June, so summer
  counts understate these to near zero. Demoting them would hide the year-end
  flow precisely when it is needed.

Widget tools are a hard exclusion, and I found that by tripping over it:
gnubok_receipt_matcher and gnubok_vat_review_widget were demoted in a first
pass and their own suites failed. A widget is rendered from the _meta.ui it
publishes in tools/list, so search-only leaves it callable but unrenderable.
Both are back in the catalog, and a general invariant now holds the rule for
every widget tool added later, verified by demoting one and watching it fail.

Only READ tools are demotable at all: gnubok_call_tool refuses writes, so a
search-only WRITE is uncallable on Claude.ai. Demoted reads stay callable
through the bridge; they are no longer listed up front.

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

* docs: code-span the connect_* identifier in DECISIONS

A bare asterisk opens an emphasis span in Markdown.

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-31 23:01:17 +02:00
Jakob Wennberg 37e50c272d feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance) (#1757)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

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

* feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget

Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A
self-hosted instance with a `bank_sync`-scoped connector key can now connect
a bank through Arcim's PSD2 credentials; the bank session id and all
transaction data stay in the instance's own database (founder decision:
tokens on the instance, proxy stateless).

- Migration 20260820124000: `connector_connections` (secret-free ledger:
  sha256 of the EB session id + account uids, service-role only),
  `connector_upstream_counters` + RPC `connector_reserve_upstream` (global
  budget under EB Annex 1 §5's 300/min, shared with hosted), and
  `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs
  REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real
  covers all of it.
- EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core
  must not import @/extensions/); the extension re-exports it, tests
  unchanged.
- lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed
  connector state (15-min TTL) so the consent redirect can use OUR
  registered EB callback and bounce back to the instance, no per-instance
  redirect URI at EB; the callback route gains that connector branch.
- app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions,
  accounts/{uid}/{balances,transactions}), never open passthrough. POST
  /auth enforces the per-company connection quota + rewrites redirect/state;
  reads/deletes verify ledger ownership; every upstream call takes the
  global budget (429 + Retry-After when exhausted).
- issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of
  v1), --bank/skv-connections-per-company + --sync-min-interval.
- Docs (SELF-HOSTING: bank connector live), DECISIONS.

Verified: 52 connect unit tests + 13 pg-real (run locally against
supabase/postgres with all migrations) + EB extension suite (225, jwt
relocation intact); full unit suite 15 979 green; tsc, guards, lint clean.
Not in this PR: SKV broker (PR5b) and instance wiring (PR6).

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

* feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance)

Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted
instance with a `skatteverket`-scoped connector key can now run the BankID
consent, file VAT/AGI and sync skattekonto through Arcim's registered
Skatteverket client; the SKV tokens are returned to the instance and stored
(encrypted) there.

- lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data
  helpers (authorize URL, code/refresh exchange with Arcim's client secret,
  the four backing-API base URLs, the API-gateway Client_Id/Client_Secret
  headers). Core can't import @/extensions/, so this duplicates the
  extension's endpoints/scope set (one integrator = Arcim), mirroring the EB
  JWT relocation.
- app/api/connect/skv/oauth/authorize-url: builds the authorize URL against
  OUR registered redirect_uri + a signed connector state, per-company SKV
  connection quota, pending ledger row.
- app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens
  to the instance; the ledger keeps only sha256(access_token) +
  sha256(refresh_token).
- app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto /
  agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as
  X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the
  token hash against the ledger, adds Arcim's gateway credentials (never
  exposed to the instance), forwards. Same per-key + global budget as bank.
- The Skatteverket extension /callback gains the connector branch
  (isConnectorState -> 302 back to the instance; code never exchanged there).
- Docs (SELF-HOSTING: SKV connector live) + DECISIONS.

Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector
branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean;
no-phantom-columns held at 380 (literal update branches). Not in this PR:
instance-side wiring (PR6).

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

* fix(connect): SKV broker review+skeptic batch: state-bound exchange, owned-only refresh, identity-number redaction, quota reservation, https-only bases, docs dedupe

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

* docs(self-host): restore the instance-wiring qualifier in the connector section

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

* fix(connect): PR #1757 review batch 2: redirect 'error' on credential fetches, mandatory ledger writes before token return, MD037

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

* fix(connect): reject encoded path separators in SKV data-proxy segments (traversal guard)

The WHATWG parser normalizes raw dot segments before the route runs;
what survives is an encoded separator inside a segment (a%2Fb, ..%2Fx,
a%5Cb), which would escape the allowlisted service base once the
upstream fetch re-normalizes. splitPath now decodes each segment and
rejects dot segments and separator-bearing values.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-31 22:40:55 +02:00
Mattsson 5131ee9085 fix(errors): point payment-account-missing message at Installningar -> Fakturering (#2088)
A user importing invoices from Visma hit INVOICE_SEND_PAYMENT_ACCOUNT_MISSING
on mark-sent and could not find where to add the account: the message said
"under Fakturering" without saying it lives in settings. Spell out the full
path (Installningar -> Fakturering) in the Swedish and English messages and
the remediation line, matching the wording other structured errors already use.

Copy-only: error code, status, and behavior unchanged.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:26:14 +02:00
Jakob Wennberg 10bbfb9d79 fix(mcp): make agent failures diagnosable from the log, not just from the source (#2087)
* fix(mcp): make agent failures diagnosable from the log, not just from the source

Mining event_log for mcp.tool_called: VALIDATION_ERROR ran at about one a day
until 2026-08-25, then jumped to a hundred a day and stayed there. One
integration's gnubok_get_kpi_report has been refused 604 times over seven days
and is still failing.

The cause is the unknown-parameter guard from #1856, which is correct and
stays. The caller is even told precisely what is wrong: getStructuredError puts
"Unknown parameter "x" for <tool>. Valid parameters: ..." into message_en,
which the agent receives.

What was broken is what we recorded about it. Two things, both cheap:

- Telemetry logged only message_sv, which for VALIDATION_ERROR is the registry
  default "Förfrågan innehåller ogiltiga uppgifter." and names neither the
  parameter nor the tool. A seven-day outage was indistinguishable from a typo,
  and the cause was only findable by reading the dispatcher source. errorDetail
  now carries message_en, stored only when it differs from errorMessage, so the
  many domain failures whose message_sv is already the specific text cost
  nothing extra.
- errorKind said 'company_access_denied' for all 604, because the arg guard
  throws inside the company-routing try. That is an active misdirection: it
  sends triage looking for a tenancy bug that does not exist. Exactly two
  things in that block raise VALIDATION_ERROR, the unknown-parameter guard and
  a malformed company_id, and neither is a permissions failure, so they now log
  as 'invalid_arguments'.

Tests reproduce the production call shape and were verified to fail when either
fix is reverted.

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

* test(mcp): derive the telemetry payload type from the event contract

Two review findings, both valid.

The local ToolCalledPayload interface was a hand-maintained duplicate that
omitted sessionId and half the errorKind union. That is not cosmetic: it is why
errorDetail could be added to the emitter and to lib/events/types.ts while the
test file type-checked against a stale shape. Deriving it from
EventPayload<'mcp.tool_called'> removes the drift.

The null-detail assertion was also conditional, so it would have passed on a
wrong-but-present value. Replaced with two determinate cases: a scope denial
carries both languages without duplicating either, and the unknown-tool exit,
which supplies no diagnostic, stores null.

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

* test(mcp): pin the scope name in the diagnostic assertion

'A different string' passes on any placeholder. The reason errorDetail is worth
storing is that it names what the caller lacks, so assert that.

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-31 22:24:28 +02:00
Jakob Wennberg 523beaa18b fix(mcp): give the missing-chart-accounts refusal a code agents can dispatch on (#2075)
getStructuredError resolves an error with no `code` to UNKNOWN_ERROR, whose
registry text is the constant "Något gick fel. Försök igen." So an agent
branching on `code` saw "unknown" for a failure whose own message already
named the accounts and the two tools that fix it.

On production that was 40 of the create_voucher failures in 60 days.

Everything needed already existed: the ACCOUNTS_NOT_IN_CHART code, its
registry entry, its remediation pointing at the chart-of-accounts resource,
and a typed error class that storno-service already throws. This path just
never attached the code.

Attached with Object.assign rather than by throwing AccountsNotInChartError,
because that class builds its own generic English message from the account
list and would discard the richer Swedish one, which is the more useful half.

Pinned by a test asserting the same message WITHOUT the code still resolves to
UNKNOWN_ERROR, so this cannot silently regress.

Not fixed here: the MCP server has ~155 bare Swedish-prose throws against 3
uses of codedError(), so most domain failures remain undispatchable. This
fixes the one instance telemetry actually proved rather than converting 155
sites blind.

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-31 22:01:27 +02:00
Jakob Wennberg 413a0978c0 fix(v1): attribute every REST write to the API key that made it (#2074)
commitEntry() already reads getActor() and forwards it to
commit_journal_entry, which stamps journal_entries.committed_actor_* and the
audit_log COMMIT row. But runWithActor had exactly one production call site,
the pending-operations commit, so everything reaching the ledger by any other
route committed anonymously.

Setting the scope once in withApiV1 attributes every v1 write, including the
ones that get to the ledger several frames down through reverseEntry,
correctEntry and the supplier-invoice paths. No signature changes, no
migration.

The severity is not the headline aggregate. 184 873 of the 193 466
unattributed entries are source_type='import', bulk SIE migration with no
meaningful actor. Excluding those it is 8 593 of 11 009, and it concentrates
where it matters most: 99.8% of storno entries and 100% of correction entries
had no actor. Those are the two sanctioned rättelse paths under BFL 5 kap.
5 §, where "who did this, and when" is a legal question.

Only the authenticated branch is wrapped. The anonymous public path has no
attribution worth recording.

Verified by removing the wrapper and watching 3 of 4 tests fail; the fourth,
which checks AsyncLocalStorage does not leak between requests, correctly still
passed.

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-31 21:49:51 +02:00
Jakob Wennberg 36123cef23 feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget (#1751)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

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

* feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget

Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A
self-hosted instance with a `bank_sync`-scoped connector key can now connect
a bank through Arcim's PSD2 credentials; the bank session id and all
transaction data stay in the instance's own database (founder decision:
tokens on the instance, proxy stateless).

- Migration 20260820124000: `connector_connections` (secret-free ledger:
  sha256 of the EB session id + account uids, service-role only),
  `connector_upstream_counters` + RPC `connector_reserve_upstream` (global
  budget under EB Annex 1 §5's 300/min, shared with hosted), and
  `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs
  REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real
  covers all of it.
- EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core
  must not import @/extensions/); the extension re-exports it, tests
  unchanged.
- lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed
  connector state (15-min TTL) so the consent redirect can use OUR
  registered EB callback and bounce back to the instance, no per-instance
  redirect URI at EB; the callback route gains that connector branch.
- app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions,
  accounts/{uid}/{balances,transactions}), never open passthrough. POST
  /auth enforces the per-company connection quota + rewrites redirect/state;
  reads/deletes verify ledger ownership; every upstream call takes the
  global budget (429 + Retry-After when exhausted).
- issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of
  v1), --bank/skv-connections-per-company + --sync-min-interval.
- Docs (SELF-HOSTING: bank connector live), DECISIONS.

Verified: 52 connect unit tests + 13 pg-real (run locally against
supabase/postgres with all migrations) + EB extension suite (225, jwt
relocation intact); full unit suite 15 979 green; tsc, guards, lint clean.
Not in this PR: SKV broker (PR5b) and instance wiring (PR6).

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

* chore(connect): update ledger pg test to re-versioned migration 20260831200000

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

* fix(connect): redact opaque path segments before usage metering; correct stale RPC-source comment

GET/DELETE /sessions/{id} and /accounts/{uid}/... carry the raw EB
session id / account uid in the pathname; metering persisted it in
cleartext next to the ledger that stores only sha256(handle). Opaque
segments (UUID, long hex, long base64url) now become ':id' before the
connector_usage_events insert. Migration comment now cites the real
prior RPC source (20260831190000).

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

* fix(connect): percent-encoded path segments count as opaque in metering redaction

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

* fix(connect): PR #1751 review batch: https-only EB URL, body-covering timeout, quota reservation, delete-after-success, doc fix

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

* fix(connect): bind the /sessions code exchange to its verified pending state; ceiling +1

Verified state signature, key/service match, and an existing pending
row now precede the EB exchange; a concurrently consumed state closes
the just-minted upstream session and 409s. no-phantom-columns ceiling
391 for countHeldConnections' computed .or() timestamp filter.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-31 21:46:50 +02:00
Jakob Wennberg 0ff1b05553 feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly (#1748)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

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

* chore(connect): update pg test to re-versioned migration 20260831190000

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

* fix(connect): RPC errors answer 503 not 401; X-Connector-Key wins over Authorization

A hosted DB error mapped to 401 made the instance sync treat a pooler
blip as key revocation and delete its entire connector grant cache,
zeroing the 72h offline grace. 503 lands in the sync's keep-grants
branch (already test-pinned). Bearer-first extraction hashed the
upstream token on dual-header proxied calls, 401ing the exact shape
X-Connector-Key exists for.

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

* fix(connect): sync deletes grants only on a body-proven connector rejection, never bare 401/403

A WAF challenge page, edge deployment protection, or an egress proxy
answers 401/403 without the hosted app ever running; trusting status
alone wiped the instance's 72h offline grant cache within the hour.
Deletion now requires the hosted route's own rejection code in the
JSON body; codeless 401/403 keeps grants (server_error branch).

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

* fix(connect): PR #1748 review batch: https-only connect URL, atomic pin, prefix-gated Bearer, deferred metering, entitlements validation, integer months

- GNUBOK_CONNECT_URL must be https (http only for loopback); invalid or
  plaintext URLs disable the connector instead of sending the key.
- instance_url pin update filters on IS NULL; a lost race re-reads and
  reports the winner's pin.
- extractConnectorKey: a Bearer is the connector credential only with
  the gnubok_ck_ prefix; upstream Bearer falls through to X-Connector-Key.
- Usage metering runs via after() off the response path (inline outside
  a request scope).
- Sync validates entitlements shape: unknown status or malformed
  current_period_end keeps grants (server_error), never deletes.
- issue-connector-key rejects fractional --months.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-31 20:51:17 +02:00
Jakob Wennberg cfce2de925 feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' (#1747)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* fix(entitlements): self-host connector gate honors only source=connector grants

The trial-seed trigger (seed_trial_capability_grants) writes 30-day
source='trial' rows for bank_sync and skatteverket on every company
insert, self-hosts included. The partitioned self-host gate read every
active grant, so a fresh self-host company held every connector
capability for a month with no connector key (CodeRabbit finding on
#1747, verified against migration 20260818170000).

hasCapability and getCompanyIdsWithCapability now add
.eq('source', 'connector') on a self-host; getCompanyEntitlements skips
non-connector rows there. Hosted reads every source exactly as before
(covered by a test asserting no source filter is applied).

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

* test(entitlements): pin the early-grants wave on the self-host partition, fix stale docstrings

Address the review round on the readGrants() merge resolution:

- Add two recording-mock tests for getCompanyEntitlements with teamId
  (the dashboard layout path): on a self-host the early grants read must
  narrow to the connector keys and carry source = 'connector'; on hosted
  it reads every paid key with no source filter. The in-loop source check
  masked a lost narrowing, so this pins the query itself.
- Move the getCompanyEntitlements docstring back above the function and
  replace "Self-hosted holds everything" with the actual partition.
- keys.ts: say that lib/connect/instance arrives with stack PR #1748 so a
  reader on main does not chase a module that is not there yet.
- DECISIONS.md: describe the InvoiceInboxWorkspace.tsx merge resolution
  accurately (set to the #1753 blob origin/main carried at push time, not
  the merged parent's) and note that stack children must merge this
  branch forward.

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

* chore(connector): merge origin/main, re-version connector migration to 20260831170000

Migration 20260820122000 predates versions already applied to prod
(latest 20260831150000); renamed to keep Supabase branching history
monotonic. Test reference updated.

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

* fix(entitlements): own-credentials seam so self-hosts running their own EB/SKV are never connector-gated

Forward-ports the connector-mode seam's own-credentials half from the
instance-wiring layer: a self-host with its own Enable Banking or
Skatteverket credentials holds that capability outright, exactly like
every other local capability. Without this, upgrading an own-credentials
self-host silently killed working bank sync and SKV integrations and
showed a hosted subscription upsell whose remedy does not exist for a
self-host. capability_blocked copy gains a self-host variant naming
GNUBOK_CONNECTOR_KEY; stale all-on comments updated at the two gate
call sites.

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

* fix(entitlements): route the pending-op capability block through capabilityBlockedError for self-host copy

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

* fix(entitlements): PR #1747 review batch: connector-grant expiry CHECK, test cleanup, DECISIONS correction

- Migration 20260831180000: CHECK (source <> 'connector' OR expires_at IS
  NOT NULL); connector grants are a short-lived offline cache, a NULL
  expiry would be a permanent unlock nothing revokes. pg test added.
- beforeEach vi.clearAllMocks() in the three entitlements test files.
- DECISIONS entry corrected: the trial seed DOES write trial rows for
  bank_sync/skatteverket; what it never writes is source='connector' or
  the connector-only keys.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-31 17:55:29 +02:00
Mattsson 1d63e0f72b fix(settings): reachable opt-in toggle for the bookkeeping digest (#2085)
* fix(settings): reachable opt-in toggle for the bookkeeping digest

The digest toggle shipped in PR #2078 inside the push-notifications
extension's settings panel, but that extension is not enabled on hosted
(extensions.config.json), so the panel never renders and nobody could
opt in: the cron and email path were live with an unreachable switch.

Adds an "Aviseringar" group on the core account settings tab with the
email_digest_enabled toggle (upsert on notification_settings, own-row
RLS), localized in both sv and en. The extension panel keeps its copy
for installs that enable push-notifications.

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

* fix(i18n): natural English label for the digest toggle

Skeptic note: "New to bookkeep" is awkward; "New items to book" mirrors
the Swedish "Nytt att bokföra".

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

* fix(settings): log digest-toggle read/write failures

Compliance swarm (ASVS V16.2, SOC 2 CC7.2): the catch blocks swallowed
errors silently; surface them via console.error like the sibling
notification settings component.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 17:39:29 +02:00
Jakob Wennberg 9fe37b85b5 feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended

An API key gets an optional ceiling in SEK. Above it the agent may still stage
the work, it just may not finish it alone: a human approves the same verifikat
in the app. Default is NULL, so every existing key keeps its behaviour and
turning this on is entirely opt-in.

Enforced at the two places an API key reaches the ledger, and at both the
refusal happens BEFORE the point of no return:

- MCP: in commitPendingOperation, before the atomic claim, so the operation
  stays 'pending'. Behind the claim it would be caught by the generic handler,
  marked terminal 'rejected', and the staged verifikat would be gone.
- REST: in journal-entries.commit, before commitEntry, so the draft stays a
  draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run
  refuses too, rather than promising a voucher number the key cannot deliver.

Not enforced inside commit_journal_entry: a RAISE there is swallowed by
engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function
that issues every voucher number.

Operations whose amount is only known during dispatch (batch allocation, bulk
booking, the settlement link paths) fail OPEN behind an explicit allowlist.
Pricing them ahead of dispatch would be a guess, and a wrong guess silently
breaks batch allocation the day someone sets a limit. The allowlist is derived
from what production actually stores: create_voucher carries total_debit on
1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003,
create_supplier_invoice_from_inbox carries total on 208 of 228.

This is a blast-radius cap, not a security boundary. A per-entry ceiling is
defeated by splitting one entry into several, and an LLM will find that, so
UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is
one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the
primitive that actually bounds exposure and is left to a separate change.

The guard is written NULL-first everywhere. An absent, unparseable or
non-positive ceiling always means unlimited, never "block everything".

Agents read their own ceiling from gnubok_get_agent_briefing instead of
discovering it by burning a staged verifikat on a 403.

Changing a ceiling is auditable: it now renders in behandlingshistorik
(BFL 5 kap. 11 §). The audit trigger already fired on the column, but the
report dropped the event because the field was not in its diff map.

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

* chore(skill): regenerate accounted-api skill for the new commit pitfall

apiskill:check is a ratchet: the generated reference must match the endpoint
registry. Never hand-edited.

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

* test(agents): pin the DB default itself, and declare the briefing field required

Two review findings, both real:

- the default test stored an explicit NULL, so it stayed green even if the
  column default changed to a positive ceiling: the one change that would
  silently start blocking every existing key. It now omits the column.
- gnubok_get_agent_briefing documents unattended_commit_limit as always
  present and emits it unconditionally, so it belongs in the output schema's
  required list.

Declined the NOT VALID constraint suggestion, with the reason recorded in the
migration: api_keys is 388 rows / 768 kB in production.

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

* docs(agents): name the TOCTOU window in the REST ceiling check

A security scan flagged that the line sum is read before commitEntry, so a
concurrent write to the draft's lines can post over the ceiling. Real, and
accepted: closing it means enforcing inside commit_journal_entry, where a RAISE
becomes a retryable 500 and destroys the staged operation on the MCP path.

Recorded in the code rather than left implicit, so nobody later mistakes this
for a hard control. A per-entry ceiling is already defeated by splitting, which
needs no race; the cumulative rolling-window limit is the primitive that bounds
exposure.

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

* fix(agents): price the settlement and batch paths that were bypassing the ceiling

A security scan flagged that known money-posting operations fail open, and it
was right. The first cut priced only create_voucher, categorize_transaction and
create_supplier_invoice_from_inbox, on the belief that the batch and settlement
paths computed their totals only inside SQL at dispatch. Production says
otherwise: the staged preview already carries the amount, because it is the
number a human is shown when approving the operation.

Over the last 120 days each of these is present and numeric on 100% of that
type's staged rows:

  link_transaction_journal_entry  transaction_amount  1369 rows
  bulk_book_transactions          tx_sum               273 rows
  link_supplier_invoice_voucher   payment_amount        55 rows
  match_batch_allocate            total_allocated       24 rows
  mark_invoice_paid               total                  3 rows

So a key with a ceiling could post any amount through the four largest
settlement paths. Now priced, and the ceiling applies.

Only reconciliation_match stays unpriced: it carries pair_count, which is a
COUNT. Pricing off that would compare pairs against kronor, which is worse than
not enforcing. link_document_to_voucher and attach_document_to_transaction move
no money at all; the transaction_amount they carry is context, not a posting.

Genuinely unpriceable types still fail OPEN. This control can only ever narrow
what a key does, and a wrong guess at an amount blocks a legitimate commit, so
guessing high would leave an agent unable to work.

Adds a test that walks the whole allowlist, so a typo'd field name cannot
silently make a type unpriceable again: that is exactly the hole this closes.

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

* fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room

The tools/list context-budget bench sits at 65 000 tokens and main now leaves
roughly 20 tokens of headroom. An always-present field on the briefing's output
schema costs about 85, so this addition alone pushed the bench red.

The bench's own note is explicit that the answer is to demote a tool rather than
raise the ceiling, so raising it here would be the wrong trade for a
nice-to-have.

Nothing is lost that matters: the operation is never destroyed when it is
refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs
one round trip and no work. That error already carries both attempted and limit,
and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing
is worth doing once there is budget to spend.

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

* docs(api): spell affärshändelse correctly in the commit pitfall

Fixed in the route's registerEndpoint pitfalls, which is the source; the skill
reference is regenerated from it and never hand-edited.

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-31 15:53:53 +01:00
Mattsson 2814d70cb4 feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076)
* feat(bookkeeping): cascade opening-balance corrections to later years

Fortnox/SIE migrations book one IB verifikat per imported year, so
correcting one year's ingaende balans left every later year's linked IB
carrying the stale figures (support case: a 2019 IB fixed in Fortnox
after export never reached Accounted, skewing all subsequent saldon).

- POST /api/import/opening-balance/correct accepts cascade: true and
  applies the correction's per-account delta to each subsequent year's
  IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts).
  Locked/closed/lock-dated/bokslut years are skipped and reported, never
  forced; a failed year is compensated and the cascade continues.
- CorrectOpeningBalanceDialog offers the cascade as a default-checked
  checkbox when later years have their own IB verifikat, and when the
  current year is blocked it points at the earliest open year's IB
  verifikat instead of dead-ending.

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

* fix(bookkeeping): atomic cascade replacement + review findings for PR #2076

- Cascade now books each later year through replaceOpeningBalanceEntry
  (one RPC transaction: storno + corrected voucher + pointer swap, CAS
  on the expected old entry), removing the create/reverse/relink window
  that could leave a period linked to a reversed IB entry.
- Cascaded verifikat keep the original lines verbatim (descriptions and
  dimensions) and append labelled IB-rättelse adjustment lines per
  changed account instead of collapsing per-account nets.
- Year-end lookup fails closed: a query error skips the period instead
  of reading as 'no bokslut'.
- Dialog always sends the cascade flag (a cold reference cache no longer
  silently disables the default-on cascade), the success toast separates
  blocked years from failed years needing review, and the checkbox notes
  that a resultat correction may still need an omforing to 2091.

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

* feat(bookkeeping): Fortnox-style inline IB correction without storno

Founder decision 2026-08-31: IB edits in open unlocked years should feel
like Fortnox (change the number, no extra verifikat) instead of always
producing a storno + rebook pair in serie A.

- Migration 20260831150000 redefines correct_entry_lines_inline to admit
  source_type 'opening_balance' with three IB guards: only the period's
  current linked IB, no posted bokslut on the period, and replacement
  lines restricted to balance-sheet accounts (class 1-2). The entry id
  never changes, so fiscal_periods.opening_balance_entry_id stays valid
  and every report reads the corrected lines automatically. Storno,
  year_end and vat_settlement stay excluded; locked/closed/lock-dated
  periods are still refused (BFL 5 kap 5 par: storno is the only track
  there).
- New POST /api/import/opening-balance/correct-inline: diff-based strike
  and replace inside the same IB verifikat, same OB_* pre-flight codes
  as the storno route, RPC rule violations surfaced verbatim as 409
  OB_INLINE_REFUSED. With cascade: true the per-account delta is
  appended as labelled IB-rattelse lines inside each later open year's
  own IB verifikat (cascade mode 'inline'): a multi-year correction
  with zero new verifikat.
- CorrectOpeningBalanceDialog computes the row diff (untouched lines
  keep ids, descriptions and dimensions) and posts to the inline route;
  copy updated (no storno language), toast reports inline updates.
- In-app agent guidance (shared-rules) updated to describe the inline
  flow and the cascade checkbox.
- Tests: pg-real suite for the redefined RPC (IB accept, linked-IB
  guard, bokslut guard, P&L guard, structural types still refused,
  non-IB unaffected), route tests, cascade inline-mode unit tests.

The storno-based /correct route and engine paths are untouched: they
remain for the import replace flow and API compatibility.

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

* fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance

The verifikation-draft period-lock gate test uses the literal
'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB
bullet in shared-rules carried the same string in every prompt and broke
the open-period assertion. Reference Bokföringslagen generically instead.

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

* fix(bookkeeping): derive inline cascade delta from the rattelse log

Swedish-review finding on PR #2076: the cascade delta was computed from
a route-side line snapshot read before the RPC, which a concurrent edit
could theoretically desync from what the RPC actually committed. The
delta now comes from the RPC's own journal_entry_rattelse_log row
(struck_lines/added_lines snapshotted inside the RPC transaction), so
the cascade always matches the committed base correction. Also softened
the blocked-year guidance copy (declared-status is an assumption, not a
verified fact).

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

* fix(bookkeeping): visible cascade failure + dimensions-aware no-op check

CodeRabbit round-2 findings on PR #2076:
- A cascade that failed to run (log fetch error, unexpected throw) was
  returned as an empty successful summary, so the dialog reported
  nothing wrong while later years stayed unverified. Both routes now
  mark it failed: true and the dialog tells the user to check later
  years' opening balances.
- The RPC's no-op guard compared account/amount/description only, so a
  dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The
  comparison keys now include canonical dimensions jsonb text (fixed in
  the unmerged 20260831150000 migration).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:01:11 +02:00
Mattsson f216a60bf8 feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning

User request: rabatt i procent per artikelrad, and a marking field
separate from Er referens.

- invoice_items.discount_percent (0-100, default 0): line_total and
  vat_amount are stored NET of the discount. Shared exact-ore math in
  lib/invoices/line-amounts.ts (gross, discount, net) used by the web
  builder, staged-operation commit, editor preview, PDF, and Peppol.
  Undiscounted lines keep the legacy unrounded qty*price byte-identical.
- ROT/RUT deduction computes on the discounted net line total.
- invoices.invoice_marking: printed on the PDF next to the references
  and mapped to Peppol BT-10 BuyerReference (marking wins over
  your_reference; either satisfies the BT-10 requirement).
- Peppol renders the discount as a BG-27 line AllowanceCharge
  (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount).
- Editor: "Lagg till rabatt" in the row menu (same reveal pattern as
  ROT/RUT), Markning row next to Er referens, forval chip, review
  dialog shows discounts and marking.
- Plumbed through v1 REST projections, MCP create/get/update invoice
  tools, pending-operations update path, and copy-invoice (discount
  copied; marking deliberately not, it is recipient-specific).

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

* fix(invoices): carry discount_percent through every deduction, credit, convert and preview path

Skeptic + CI findings on the discount/marking feature, one pass:

- generateRotRutLines and propose-send-lines now pass discount_percent
  into computeDeduction: the send/credit/cash verifikat booked 1513 on
  the GROSS line while deduction_total, the PDF and the Skatteverket
  claim carried the net, stranding the difference on 1513 and pushing
  1510 negative once the customer paid. Test pins 1513=3000/1510=7000
  for a 20%-discounted 10 000 kr ROT line.
- preview-pdf route accepts discount_percent (net totals + net-based
  deduction) and invoice_marking; the editor now sends the marking, so
  the preview equals the invoice it becomes.
- Credit notes carry discount_percent (buildCreditNoteItem, v1 credit
  route select+insert, MCP credit executor) and invoice_marking, so the
  kreditfaktura face arithmetic multiplies out and shows the Rabatt
  column (ML 17 kap 24 §).
- Proforma->invoice convert copies discount_percent + invoice_marking:
  the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH
  and lost the rebate on the next builder pass.
- Editor hides the discount menu in self-billed mode (the self-billed
  wire shape has no discount; previewed net would book gross).
- MCP staging and commitCreateInvoice reject a non-number
  discount_percent (a string coerced past the range check but was
  ignored by the totals math and still stored).
- Regenerated skills/accounted-api (apiskill:check CI failure).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:34:06 +02:00
Mattsson 84c8e1ce59 fix(inbox): never extract the receiving company as its own supplier (#2080)
* fix(inbox): never extract the receiving company as its own supplier

The model sometimes reads the Kund/Kunduppgifter block of bank agreements
and similar documents as the issuer, so the inbox offered to create the
user's own company as a leverantor. Two layers: a supplier-direction rule
in the extraction prompt (issuer, never the customer/recipient block), and
a deterministic post-extraction guard that nulls the supplier block when
its org number, derived VAT number, or exact name equals the receiving
company's own. All AI extraction paths (upload, deferred, retry,
attach-document, document-extraction, MCP) pass the company identity.

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

* fix(inbox): keep the own-company guard alive for photographed documents

Skeptic findings on PR #2080: normalizeImageForExtraction rebuilt the input
without ownCompany, so the guard never fired for HEIC/oversized phone photos
(the motivating case). Spread the original input instead. Also let a
provably different extracted org number outvote a name coincidence, and
accept 12-digit personnummer-form own org numbers (19/20 century prefix,
enskild firma) alongside the 16-prefixed organisation form.

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

* docs(compliance): add RoPA entry for the own-company-identity lookup

Compliance swarm (GDPR Art. 30): fetchOwnCompanyIdentity reads
companies.name and org_number on every extraction; record the processing
activity (transient in-memory comparison, never sent to the model).

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

* fix(inbox): log own-company identity lookup failures instead of failing silent

Compliance swarm (SOC 2 CC7.2): the bare catch in fetchOwnCompanyIdentity
made a persistently broken lookup (RLS misconfig, DB outage) disable the
guard invisibly. Also pin the VAT-number match across prefixed and bare
digit forms in the test matrix.

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

* fix(inbox): surface maybeSingle query errors in the own-company lookup

CodeRabbit: maybeSingle() reports RLS/query failures in error without
throwing, so the failure log added for SOC 2 CC7.2 never fired for exactly
those cases. Throw the reported error into the existing catch (still fail
open to nulls).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:13:14 +02:00
Mattsson fc54e79e08 fix(auth): scope the home-domain ok cookie to the signed-in user (#2083)
The gnubok-home-ok cache cookie stored only the host, so any account that
signed in within the 15-minute TTL window inherited the previous user's
"this is home" verdict in the same browser and skipped the brand-host
bounce entirely: a user with no ties to a white-label brand could land
inside the branded shell instead of being redirected to the canonical
domain (amnas account-switch repro, two logins 9 s apart).

The cookie value is now userId~host and the middleware only skips the
affinity check when both match the current session. Old host-only cookies
never match, so the check re-runs and the format self-migrates; no
sign-out hook is needed because a foreign verdict misses by construction.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:58:17 +02:00