Commit Graph
232 Commits
Author SHA1 Message Date
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00
MattssonandClaude Fable 5 f43a6653f1 feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date

payment_date drives the booking entry date but was only editable via the
v1 PATCH. Close the gap on both remaining surfaces:

- New staged MCP write tool gnubok_update_salary_run (search-only
  catalog; tools/list budget is at zero headroom) accepting the exact
  v1 PATCH field set: payment_date, voucher_series, notes. Draft-only
  with the same optimistic lock semantics, via a new shared service
  lib/salary/update-run.ts used by both the staging preflight and the
  commit executor.
- Run header UI: payment date on a draft run is now an inline date
  input (prefilled, committed on blur/Enter, snaps back on failure),
  saved through the existing internal PATCH. Read-only once not draft.

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

* fix(salary): op-type migration, calc invalidation on date change, scanner compliance

Consolidated CI + review fix pass for #2041:

- pg-real: add 'update_salary_run' to pending_operations_operation_type_check
  (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1).
- Swedish accounting review: a payment_date change on a draft run now clears
  every roster row's calculation_breakdown (shared service and internal PATCH
  alike), so both book preflights refuse the run until a recalculation has run
  against the new date; skatteavdrag and the AGI redovisningsperiod follow the
  payment month. Staging preview exposes invalidates_calculation and the next
  hint states the clearing.
- no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with
  a documented reason for the inherent patch-shaped UPDATE payload.

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

* fix(salary): close skeptic findings on payment_date editing

Skeptic round 1 refuted two paths; both closed:

- Retry idempotency (correctness): the calculation_breakdown clear was
  gated on new-date-differs-from-stored, so a retry after a partial
  failure (header committed, clear failed) compared against the already
  updated date and skipped the clear forever, leaving a stale
  calculation bookable. The clear is now gated on payment_date being
  SUPPLIED, on all three surfaces (shared service, internal PATCH, v1
  PATCH: the v1 route previously had no clear at all and bypassed the
  invariant).
- Kontantprincipen (compliance): AGI derives its redovisningsperiod
  from period_year/period_month while the verifikat books on
  payment_date, so a cross-month payment_date change could book salary
  in one month and declare it in another. All three edit surfaces now
  refuse a payment_date outside the run's period month with the new
  structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date
  input is min/max-bounded to the period month.
- The internal PATCH update is now optimistic-locked on status='draft'
  (races return 400 instead of silently writing), matching the v1 PATCH
  and the shared service, and the clear cannot fire for a run that left
  draft.

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

* fix(salary): carry book_skattekonto op types through the constraint re-create

The sibling migration 20260830130000 (merged from main) re-created
pending_operations_operation_type_check with book_skattekonto_row and
book_skattekonto_rows. This branch's 20260830150000 sorts after it and
re-creates the constraint wholesale, so its list must be that migration's
superset or the two values would be silently revoked at apply time.

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

* fix(salary): value-validate internal PATCH and grandfather out-of-period dates

Two skeptic follow-ups:

- The internal PATCH now validates values, not just keys: JSON body must
  be an object, payment_date must be ISO (shared ISO_DATE_RE),
  voucher_series a single A-Z letter, notes a string of max 2000 chars
  or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing
  unvalidated can reach the DB through the whitelist.
- Creation does not (yet) couple payment_date to the period month, so a
  legally created out-of-period date must stay correctable. All three
  edit surfaces now allow day adjustments within the run's CURRENT
  payment month as well as the period month (grandfather clause); no
  move can introduce a new wrong month.

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

* fix(salary): resolve migration version collision with delete_draft_invoice

Main's delete_draft_invoice PR landed on the same 20260830150000/150001
versions and also re-creates pending_operations_operation_type_check.
Rename this branch's pair to 20260830160000/160001 (applies last) and
carry delete_draft_invoice through the wholesale re-create so nothing is
silently revoked. Final list = sibling's list + update_salary_run.

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

* docs(salary): regenerate accounted-api skill for the new PATCH pitfalls

apiskill:check byte-compares the generated skill against the registry;
the two pitfalls added to the v1 salary-runs PATCH endpoint made
references/salary-runs.md stale and failed Core Build.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:23 +02:00
MattssonandClaude Fable 5 9f8fa1b692 feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval

Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.

- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
  with an explicit userId param (service-role clients null auth.uid());
  the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
  INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
  route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
  outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
  approval, risk 'high' (both outcomes irreversible, never
  auto-committed), catalogVisibility 'search' (tools/list budget at zero
  headroom)
- delete_draft_invoice commit executor delegating to the shared service,
  plus pending_operations CHECK constraint migration pair
  (20260830100000/100001), risk tier, scope map, Granskning vocabulary
  and sv/en labels

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

* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main

Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.

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

* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint

apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.

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

* fix(invoices): pin staged delete outcome and align v1 risk metadata

Skeptic findings on PR #2036:

- Outcome pin: gnubok_delete_draft_invoice stages
  expected_invoice_number alongside invoice_id; the executor passes it to
  deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
  draft's number changed since staging. An unnumbered draft finalized
  between staging and approval is now auto-rejected with a message naming
  the new number, instead of silently switching from the approved hard
  delete to a makulering. Ops staged without the pin keep legacy
  semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
  the delete_draft_invoice pending-op tier (both outcomes irreversible);
  generated accounted-api docs regenerated.

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

* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision

Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:57:28 +02:00
MattssonandClaude Fable 5 32cb8a22ce feat(mcp): Swedish keyword synonyms in tool search index (#2043)
gnubok_search_tools matched only English tool names and descriptions, so
Swedish queries like "bankkonto" or "lönekörning" returned zero hits.

Adds an optional keywords field on tool definitions, folded into the
search matcher (name + description + keywords, case-insensitive, same
ranking family as name matches) and seeds Swedish domain terms on 152
tools. Keywords are matcher-side only: they are never serialized into
tools/list or search_tools output, since that payload budget has zero
headroom. Guarded by new tests in search-tools.test.ts and
lazy-auth.test.ts.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:55:09 +02:00
MattssonandClaude Fable 5 15df4c741c feat(mcp): supplier and date filters on list_supplier_invoices (#2035)
* feat(mcp): supplier and date filters on list_supplier_invoices

Add supplier_id, supplier_name (case-insensitive substring, resolved
server-side against the suppliers table), date_from and date_to
(inclusive, on invoice_date) to gnubok_list_supplier_invoices, mirroring
the v1 REST supplier-invoices filter shapes (eq on supplier_id, gte/lte
on invoice_date). Unknown supplier_name returns an empty result without
touching the invoice table; malformed dates and non-uuid supplier_id
fail loudly with pointer messages.

Schema text is kept deliberately terse: the tools/list payload guard
sits at near-zero headroom, so the date format hint lives once in the
tool description and the redundant status enum prose was trimmed.

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

* fix(mcp): bound the supplier_name match set fed to the IN clause

PR Agent review flagged the unbounded id list: a broad substring on a
large supplier register could build an IN clause past PostgREST URL
limits. Cap name matches at 200 (cap + 1 fetched so overflow is
detected) and fail loudly with a refine hint instead of degrading.

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

* fix(mcp): loud validation, literal name matching, truncation signal on list_supplier_invoices

Skeptic review refuted three behaviors; all three fixed:

1. Silent truncation: date filters invite period reconciliation but the
   50-row cap was unsignalled. The invoice query now uses count exact and
   returns total_count and has_more (same contract as list_invoices),
   with a unique-id order tiebreaker so identical calls return identical
   subsets.
2. Silent filter drop: non-string values for the new params (and blank
   supplier_name) fell through typeof guards and returned the UNFILTERED
   ledger presented as filtered, the same class arg-guard exists for.
   They now throw clear errors. Dates are also calendar-validated, so
   2026-02-30 fails loudly instead of as a raw Postgres cast error.
3. Wildcard broadening: PostgREST rewrites * in ilike values to %, so
   Star*Mart matched Starke Martinsson AB. supplier_name is now matched
   as a literal case-insensitive substring in JS over the company's
   suppliers (fetchAllRows, parity with list_suppliers), cap unchanged.

Two redundant property descriptions dropped to fund the outputSchema
additions under the tools/list payload ceiling.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:53:45 +02:00
MattssonandClaude Fable 5 8f68421fef feat(skatteverket): expose skattekonto row booking via MCP staged operations (#2039)
* feat(skatteverket): expose skattekonto row booking via MCP staged operations

Skattekonto READ and RECONCILE were already MCP tools, but BOOKING rows
existed only behind the cookie-session extension routes. This closes the
flow on API-key surfaces:

- New staged MCP write tools gnubok_book_skattekonto_row and
  gnubok_book_skattekonto_rows (batch, 1-200 ids), catalogVisibility
  'search', STAGED_OPERATION_SCHEMA, stage-time bookability gates and
  rule-matched counter-account preview. Staging never books.
- New pending-operation types book_skattekonto_row / book_skattekonto_rows
  (CHECK constraint migration pair, risk tier medium, sv/en labels).
- Commit executor reaches the skatteverket extension through the
  registry-resolved services channel (core never imports @/extensions):
  new commitBookSkattekontoRows service wraps the SAME
  bokforSkattekontoTransactionsBatch helper the HTTP bokfor-batch route
  uses (draft + commit per row via the bookkeeping engine, requireSettled),
  with the approving user id passed explicitly (auth.uid() is NULL on the
  service client).

No booking math or account mapping changed: auth-surface exposure only.

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

* chore(migrations): move book_skattekonto constraint pair after main's newest version

origin/main gained 20260830101500 while this branch was in flight; keep
the new pending_operations constraint migrations sorted after it so they
apply in order.

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

* fix(skatteverket): resolve CI and review findings for skattekonto booking tools

- Inline the skattekonto_transactions select strings at both stage-time
  call sites so the no-phantom-columns scanner can resolve them (the
  shared const pushed the unresolvable-expression count past the
  ceiling and failed Unit tests 3/4).
- Return a fixed public message from the commitBookSkattekontoRows
  batch-level catch instead of the raw exception text; the raw error
  stays in the server log (Superagent P2).
- Add verifikat_description to the staged previews so the reviewer sees
  the exact ledger text the booking helper writes, Skatteverket motpart
  included (Swedish accounting review).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:38:30 +02:00
Jakob Wennberg d39a9719a3 fix(peppol): say Peppol send is gated per company, never absent (#546) (#2021)
Peppol sending has been live since #1780 behind a per-company access grant, but the MCP skills, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send / :mark-sent descriptions still told agents it did not exist. Every text now says gated per company (requested under Installningar > Fakturering) and keeps the restrictions explicit: aktiebolag senders, standard invoices only, Swedish org-number buyers, no MCP or v1 Peppol send verb yet, :mark-sent as the recovery step when a network-accepted send fails issuance. The skills guard test pins the truthful claim across all surfaces. Includes the regenerated agent_atom_registry seeds and skills/accounted-api references. Refs #546
2026-08-30 12:19:03 +02:00
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00
Jakob Wennberg 523fba0419 feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated.
2026-08-30 11:54:47 +02:00
4b7343d5ec fix(errors): keep the SQLSTATE when wrapping database errors (#2027)
isTransientFailure() checks the driver's error code first, and 57014
(statement timeout) is already in its transient set. But the wrapping idiom
across the codebase was `throw new Error(\`Database error: ${err.message}\`)`,
which keeps the prose and drops the code. A retryable timeout therefore
arrived anonymous and resolved to UNKNOWN_ERROR: "Något gick fel. Försök
igen." An agent cannot dispatch on that, so it retried.

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

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

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

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:05 +02:00
MattssonandClaude Fable 5 e8aa0670ca feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary

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

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

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

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

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

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

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

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

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

* chore: retrigger Supabase preview after migration-version repair

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

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

---------

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

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

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

Closes #1706

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:28:14 +02:00
17caf9d80a feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview

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

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

Closes #1642

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:35:47 +02:00
dae0aac26a fix(mcp): reconcile_match callable + two-phase efterkontroll + instant value for web-onboarded users (#1967)
* docs(plugin): starter prompt nudges the connector's onboarding guide

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

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

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

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:04:57 +02:00
c2f055b903 fix(bank): 90-day lookback is the default: long PSD2 requests make some banks kill the session (#1964)
E2E #11: the account picker's fiscal-year default sent a 365-day request
for a 405-day-old fiscal year; Swedbank answered by TERMINATING the
session: zero transactions, connection expired, no error surfaced
(initial_sync_requested_from 2025-08-26, returned min/max null). 90
days worked.

- Default lookback mode is now 'fast' (90 days), labeled rekommenderas
  on a first connect; the fiscal-year option stays but carries an inline
  'vissa banker avbryter kopplingen' note when its span exceeds 90 days,
  and the long-range helper names the real failure mode + the SIE/CSV
  path for older history.
- SIE drop card: stage + arm in ONE click (three clicks was one too
  many): after the verdict, the single button reads 'Bokför:
  oåterkalleligt (BFL 5 kap 5 §)' and the deliberate click commits with
  confirmed=true.
- Skill: never re-ask an answered question; when a connect card
  rendered, do not paste the URL as text too.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 22:36:06 +02:00
53c4d0d194 feat(mcp): approve the SIE import in the drop card + post-import efterkontroll in the skill (#1963)
E2E #10 (the first fully successful drop-card run) left the staged
import stranded until the user prodded the agent, and the agent then
improvised an excellent post-import audit (skattekonto reconciliation,
missing 1630, over-stated payroll liabilities). Codify both:

1. The drop card now carries the approval: after staging, the button
   becomes 'Godkänn bokföringen' with a two-click BFL confirmation
   (confirmed=true armed on the deliberate second click, exactly the
   pending-operations widget pattern), then 'Bokfört' + a ui/updateContext
   pointing the agent at trial balance and voucher-gap follow-ups.

2. Skill: when the user writes after a card was shown, the FIRST call is
   list_pending_operations (an empty ledger does not mean the file never
   arrived); new Step 4b 'efterkontroll' codifies the audit pass
   (trial balance vs SIE, skattekonto vs 1630 with 8423/8314/6992 for
   ränta/avgifter, auto-created bank account names, underlag coverage,
   voucher gaps); memory-first extended with memory-back (save orgnr,
   bank, fiscal year, moms period after creation so the next
   conversation needs zero questions).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 21:57:45 +02:00
ff84c8f316 fix(mcp): widget-bearing tools must be read-only: Claude.ai drops write-annotated interactive tools (#1961)
The SIE drop card kept flapping into Claude.ai's Interactive-tools list
and vanishing, and the agent could never call it: every hypothesis
(scope filter, tool-count cap, stale cache, schema shape) was
eliminated against live data until one discriminator remained: all
surviving widget tools carry readOnlyHint true and
gnubok_create_sie_upload was the only one annotated as a write.
Claude.ai accepts always-render widgets only on read-only tools and
silently drops the tool otherwise.

readOnlyHint is now true, which is also honest: the tool only mints a
short-lived upload URL; the actual write is the staged
gnubok_import_sie: the exact receipt_matcher shape (read-only widget
tool, writes via separate approval-gated tools). A guard test pins the
invariant for every future widget tool.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 20:47:22 +02:00
d8244ecaff feat(mcp): connect_migration: one-click card into the previous-system wizard (#1960)
* feat(mcp): gnubok_connect_migration: one-click connect card into the previous-system wizard

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 19:19:24 +02:00
5715dd3def docs(mcp): onboarding skill branches on the named previous system (#1959)
'Jag hade Fortnox' now has a real answer instead of one generic SIE
instruction: API-connected systems (Fortnox/BL/Briox/Wint) get two
offered paths (full migration wizard with invoices/customers/documents
vs quick SIE drop, recommended by need), Visma/Bokio get the
file-first path with the wizard as a complement after, unknown systems
get the universal SIE export ask.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 19:04:22 +02:00
0f7625535b feat(mcp): drag-and-drop SIE import card: exact bytes via tools/call, no model in the byte path (#1957)
E2E #6: the flow ordered SIE-first correctly, but the agent never
discovered gnubok_create_sie_upload, ran a local preflight, and sent the
user to the web wizard again; it also rendered a duplicate generic bank
card before the Swedbank-specific one.

1. New sie-drop widget (ui://sie-drop/app.html), rendered
   definition-level by gnubok_create_sie_upload: the user drags the
   .se/.sie file onto the card, the widget reads the EXACT bytes
   (FileReader), computes sha256 (WebCrypto), calls
   gnubok_sie_preflight via tools/call with file_content_base64 +
   sha256, shows the verdict, and on Importera stages
   gnubok_import_sie with the preflight's mappings. No network from
   the iframe, no model reproduction: byte path goes through the host
   bridge only, narrated into chat via ui/updateContext.

2. The inline size cap now applies only WITHOUT sha256: a hash-verified
   payload is byte-exact by proof, so the widget's 100 KB+ base64
   passes while unhashed model-retyped content stays refused.

3. Discovery + ordering fixes: create_sie_upload/preflight/import
   descriptions name the card path explicitly; create_company's
   history_note points at the card; connect_bank description says pass
   bank on the FIRST call when the user has named it (the duplicate
   generic card came from a bare call followed by the nudged retry).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 17:06:30 +02:00
MattssonandClaude Fable 5 fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00
6f0f4d1eea fix(mcp): flow-order signals in tool results: ask the bank, import history first (#1955)
E2E #5: the agent skipped the bank question (generic picker link) and
went straight to connect cards for a company whose fiscal year started
13 months ago (history import should come first). The skill says both,
but the agent follows tool results more reliably than skill prose, so
the rules now live in the results themselves:

- gnubok_lookup_company still_to_ask gains two flow questions: which
  bank (for the bank= deep link) and whether the bookkeeping lived in a
  previous system (SIE import BEFORE bank).
- gnubok_create_company's confirmed result computes days of history in
  the fiscal period: past 90 days it emits history_note (PSD2 cannot
  reach it; run gnubok_sie_preflight first) and reorders the remaining-
  setup message to (1) SIE import, (2) bank, (3) Skatteverket.
- gnubok_connect_bank called without bank= now opens its instructions
  with a nudge to ask for the bank and re-call for the direct consent
  link.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:54 +02:00
f338850bd0 fix: hide API-archived customers and suppliers from lists and pickers (#1927)
* fix: hide API-archived customers and suppliers from lists and pickers

The v1 API soft-archives customers and suppliers (archived_at, plus
is_active=false on suppliers) and its own list routes hide those rows
behind ?include_archived=true. No other surface filtered archived_at, so
an archived counterparty stayed a normal row in the dashboard rosters,
the internal /api/customers and /api/suppliers list routes, the MCP list
tools and every customer/supplier picker.

Apply the same canonical `archived_at IS NULL` filter on every non-v1
list and picker path:

- /api/customers GET, /api/suppliers GET (feeds the customers page and
  the supplier-invoice form)
- suppliers dashboard page (reads suppliers via browser Supabase)
- InvoiceEditor and NewRecurringScheduleDialog customer pickers; an
  invoice or schedule being edited keeps its current customer visible
  (archiving does not refuse on drafts, so a draft can point at one)
- deadlines page and CalendarWorkspace customer pickers
- InvoicePreviewCard sample customer
- gnubok_list_customers and gnubok_list_suppliers: hidden by default,
  optional include_archived boolean mirroring the v1 flag; rows now
  carry archived_at so an agent can tell them apart when opted in

Detail routes and by-id lookups are untouched: an archived row still
opens. The delete-vs-archive semantics are unchanged.

The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens
of headroom, so even the bare boolean contract crossed.

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

* test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters

The two .or('archived_at.is.null,id.eq.<uuid>') filters keep an edited
draft's archived customer selectable. The uuid is a runtime value, so the
scanner cannot resolve the expression; both columns exist and the filter is
covered by the archived-counterparty tests.

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

---------

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:15 +02:00
8ddc77fdfd feat(mcp): org-number-first onboarding: gnubok_lookup_company prefills the company from the registry (#1940)
The onboarding flow now mirrors the web wizard: ask for the
organisationsnummer first, look the company up in the public registry (one
TIC Lens call through the extracted extensions/general/tic/lib/lookup.ts,
shared with the /lookup HTTP route), and present the facts for confirmation
instead of interrogating the user.

The new gnubok_lookup_company tool (companies:read, company-independent,
default catalog) returns the registry facts, a prefilled
suggested_create_company_input, and a still_to_ask list that encodes the
same fact-vs-question rules as lib/onboarding-journey/reducer.ts: F-skatt
is a fact both ways, VAT is a fact only when positively registered (ML 17
kap 24 paragraf), moms period and accounting method are always asked, an
enskild firma's verksamhetsnamn is the user's choice, and a known fiscal
year becomes a confirm question. Registry outages degrade to the full
question list instead of failing onboarding.

The onboarding skill and the plugin's /accounted:setup command are updated
to the orgnr-first flow (plugin 1.2.0). tools/list ceiling bumped 61.2K to
61.5K with the reason documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:03:02 +02:00
151fb1384c feat(mcp): connect card widget: one-click open-in-browser button for bank and Skatteverket links (#1939)
The gnubok_connect_bank and gnubok_connect_skatteverket tools now carry
definition-level _meta.ui.resourceUri pointing at a new connect-card MCP
Apps widget. On claude.ai/Claude Desktop the tool result renders as a card
with an "Öppna i webbläsaren" button that sends the host a ui/open-link
request from the click handler (the sanctioned new-tab mechanism; custom
connectors always get Claude's confirmation modal, so the destination URL
is shown in the card). Clients that do not render MCP Apps (Claude Code)
keep the connect_url in the structured result as before.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 11:05:01 +02:00
a97943d00d fix(mcp): connect tools into the default catalog: Claude.ai cannot call search-only tools (#1936)
First real onboarding run (SilverPark, 2026-08-26): the flow worked
through signup, preview, confirm and company creation, then dead-ended
when the onboarding skill pointed at gnubok_connect_bank and
gnubok_connect_skatteverket. Both were catalogVisibility 'search', and
Claude.ai can only invoke tools present in tools/list, so the client
refused the calls itself: event_log shows the server never received
them. Search-only stays valid for reference tools, but anything a skill
tells the agent to CALL must be in the default catalog. tools/list
ceiling bumped 60.7K -> 61.2K, documented in the guard.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:41:38 +02:00
1307d4db2e fix(oauth): serve RFC 9728 resource metadata at the path-based locations Claude.ai fetches (#1915)
Claude.ai's connector setup derives the protected-resource metadata URL
from the MCP server URL and fetches it before any 401 challenge:
  /.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp
  /api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource
Both were 404 (only the root document our WWW-Authenticate header points
at existed), which the dialog reported as "Authorization with Accounted
failed". One shared builder now serves all three locations; the
path-based route answers 404 for any path other than the MCP endpoint.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 09:24:11 +02:00
MattssonandClaude Fable 5 85e039035d feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909)
* feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API

Requested by a v1/MCP user: the web UI can produce resultat- and
balansrapport for a custom period with PDF export, but REST v1 and the
MCP tools only served whole fiscal years and silently ignored
from_date/to_date.

- v1 income-statement: optional from_date/to_date (validated against the
  fiscal period via the same parseReportDateRange the dashboard uses)
- v1 balance-sheet: same, plus as_of as the natural alias for to_date
  (mutually exclusive with it)
- Unknown query params on these report routes now return
  VALIDATION_ERROR with the unknown and allowed names instead of being
  silently dropped (scoped to these routes, not a global v1 change)
- MCP gnubok_get_income_statement: from_date/to_date;
  gnubok_get_balance_sheet: as_of_date; both validate format, in-period
  and ordering, and reject unknown args (tools/list payload bench held
  under the ceiling by trimming the same tools' descriptions)
- New v1 PDF endpoints reports/{income-statement,balance-sheet}/pdf,
  byte-equivalent to the dashboard export: the K2/K3 grouping and the
  balance gate moved to lib/reports/financial-statement-pdf.ts, shared
  by both surfaces
- Both JSON endpoints echo the effective range in data.period

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

* fix(reports): range semantics, empty-date validation, and review findings on PR #1909

Consolidated resolution of the skeptic refutations, CI failures, and
CodeRabbit findings:

- Ranged income statement summed closing balances, so from_date after
  period start returned year-to-date figures mislabeled as the range
  (July revenue reported as Jan-Jul on JSON, PDF, and MCP). The trial
  balance rolls pre-range P&L activity into opening columns, so
  generateIncomeStatement now builds from period movements whenever
  fromDate is set, matching the resultatrapport convention. Full-period
  behavior is unchanged; generator-level regression tests added.
- from_date dropped from the v1 balance-sheet routes (JSON + PDF): a
  balansraking is a cumulative position, not a flow over a window
  (ÅRL 3 kap); matches the MCP tool's as_of_date-only surface.
- Empty date values (from_date=) now fail validation instead of
  silently producing a full-period report with an empty period echo
  (null-check instead of truthiness in parseReportDateRange).
- dry_run, read by the withApiV1 wrapper on every request, is tolerated
  by the strict param check instead of being rejected as unknown.
- Unbalanced balansrakning on the v1 PDF route returns 400 (caller-data
  condition), matching the dashboard export, instead of 500.
- skills/accounted-api regenerated (apiskill:check gate).
- Removed the ISO_DATE_RE import that collided with the pre-existing
  local declaration in the MCP server (TS2440 on core build).
- CodeRabbit: 401 tests for both PDF endpoints; event bus cleared in
  the new MCP test's beforeEach.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:34:21 +02:00
MattssonandClaude Fable 5 436cbf5304 fix(skattekonto): route AGI draw back to 2731 to match salary module (#1905)
* fix(skattekonto): route AGI draw back to 2731 to match salary module (#1870)

Migration 20260519160000 moved the skattekonto AGI seed to 2730 while the
salary module kept crediting 2731, splitting the employer-contribution
liability across two accounts that never net at account level (both carry
SRU 7231, so only huvudbok reconciliation exposes the drift). Revert the
system seed to 2731: BAS 2026 defines 2731 as the reported-but-unpaid
arbetsgivaravgift liability (the accrual account is 2940), and the salary
ore-residual logic is built around 2731.

Historical 2730 debits since 2026-05-19 are left for per-company reclass
verifikat; the migration touches the system seed only.

Fixes #1870

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

* fix(skattekonto): bump migration version to avoid collision with 20260825120000_create_company_for_user

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

* fix(payroll): align remaining 2730 guidance surfaces on 2731 (#1870)

Skeptic regression finding: companies booking salary manually were taught
7510/2730 by in-product guidance, so the seed revert alone would re-create
the #1870 split mirrored for them. Align every guidance surface on 2731:

- packs/loneutbetalning.yaml legal_note
- MCP payroll-monthly skill (booking recipe and rate notes)
- swedish-payroll SKILL.md + references/bas-7xxx.md (2731 convention, 2730
  group-account alternative, never mixed; accrual is 2940) + regenerated
  agent atom seed (skills:generate -> 20260825180001)
- public/docs/systemdokumentation-mall.md

Also addresses the compliance review finding that the swedish-payroll skill
contradicted the migration.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:30:39 +02:00
159823583c feat(plugin): /accounted:setup command, CONNECTORS.md, v1.1.0 (#1902)
The Claude plugin is the one-click install for Cowork and Claude Code,
so it should also be the entry to agent-first onboarding (#1814).
/accounted:setup connects the bundled connector (creating the account on
the sign-in screen if needed), hands off to the server-side onboarding
skill when the account has no company, then the bank and Skatteverket
links. CONNECTORS.md documents the single bundled connector the way
Anthropic's own plugins do. Version 1.1.0 so marketplaces that sync on
version bumps pick it up. The plugin-refs guard now also validates
commands/*.md against the server.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:39:01 +02:00
MattssonandClaude Fable 5 c6f2bebab9 fix(sie): selectable IB voucher series, smarter IB toggle on re-import, orphan-IB guard (#1896)
* fix(sie): selectable IB voucher series that never collides with the file's numbering

The Ingående balanser voucher was hardcoded to series A and created before
the file's vouchers, so it consumed the A series' next number and shifted
every imported A voucher one number higher than in the source system
(issue #1882).

- IB voucher series is now selectable in the import wizard; the default is
  the first of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER records
  (M matches the existing migration-adjustment series).
- Plumbed end to end: wizard -> /api/import/sie/execute -> executeSIEImport,
  v1 REST options.openingBalanceSeries, MCP gnubok_import_sie
  opening_balance_series -> commitImportSie.
- The wizard's 'Importera ingående balanser' toggle now defaults OFF when a
  posted IB voucher already exists inside the file's fiscal year, with a
  hint saying why.
- Orphan-IB guard in executeSIEImport: replace_sie_import deletes only
  source_type='import' entries and clears the period's OB pointer, so a
  prior import's IB voucher survived every replace cycle and each re-import
  created another one (field report: five accumulated). The import now
  skips IB creation with a warning when a posted opening_balance entry
  already exists in the period.
- MCP import_opening_balances default (false) vs web (true) documented as
  deliberate in the tool schema and DECISIONS.md.

Fixes #1882

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

* fix(sie): harden IB series fix after skeptic review (relink orphan, exclude fallback series, type-check option)

Skeptic findings on PR #1896, all four blocking items:

- Orphan-IB guard now relinks a single surviving opening-balance voucher
  as the period's OB entry (permitted by the immutability trigger while
  the pointer is NULL): without it, reports showed IB 0, year-end's
  duplicate-IB blocker never armed, and the manual IB flow could
  double-book. It also diffs the survivor's lines against the file's IB
  and calls out stale amounts in the warning instead of keeping them
  silently; reverseEntry clears the pointer again for the
  storno-then-reimport path.
- Series-less #VER records resolve to the transaction fallback series at
  import time, so the IB default picker now treats that series as used by
  the file (the same #1882 shift pattern through the fallback). The
  wizard recomputes its IB default with the effective transaction series
  once loaded.
- openingBalanceSeries is type-checked on the web execute route, the MCP
  stage, and the staged-operation commit: a non-string falls back to the
  default instead of crashing mid-import after side effects.
- The wizard's IB series select flags series used by the file and shows
  an attention line when the chosen series collides; the engine warns
  when an explicitly chosen series collides with the file's series (the
  choice is honored).

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

* fix(sie): uppercase caller-chosen IB series before persisting

Swedish accounting review on PR #1896: a lowercase series from v1 or
MCP was persisted as-is, booking a case-distinct parallel series next
to its uppercase sibling (BFL 5 kap requires one systematic series)
and slipping past the file-collision warning. Normalize centrally in
executeSIEImport, the single funnel for web, v1, and MCP.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:14:59 +02:00
MattssonandClaude Fable 5 77cacdcf34 feat(mcp): personal_number on gnubok_update_customer (#1876) (#1890)
gnubok_create_customer takes a personnummer (encrypted before approval)
but gnubok_update_customer did not, so an existing customer whose
personnummer sat in the org-number field could not be corrected via MCP.
The REST PATCH already supports it; this closes the MCP/pending-operations
gap across its three layers:

- tool inputSchema: personal_number (string or null) on the strict
  whitelist. The tool validates the plaintext before any DB read and
  mirrors the REST PATCH semantics: masked echo (********-1234 or
  ********-????) = leave unchanged, explicit null = clear, absent =
  untouched. Setting is refused unless the row ends up as an individual
  (GDPR art. 5.1 c), including via a simultaneous type change.
- CustomerChangesSchema: personal_number_encrypted (nullable, ciphertext
  shape per customers_personal_number_check 20260726110000). The
  plaintext key stays forbidden by .strict() and staging-pii-guard.
- update executor: maps the staged ciphertext onto customers
  .personal_number (set/clear/leave), re-checks the individual-only rule
  against a tampered row, and returns only personal_number_masked.

PII handling: the personnummer is encrypted at staging time
(AES-256-GCM, same path as create); pending_operations params carry only
the ciphertext and the approval preview only the masked form. Idempotency
hashing switches to the masked preview for personnummer-bearing updates
(random-IV ciphertext would break retries); other updates keep their
previous hash identity.

catalogVisibility stays 'search': tools/list is at its 59.95K token
ceiling with zero headroom (see DECISIONS.md).

Fixes #1876

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:35:02 +02:00
31e0cd6e05 feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

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

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

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

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

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

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

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

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

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

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:41:02 +02:00
MattssonandClaude Fable 5 1fa34aa7ca feat(skatteverket): repair notification recipients + make the agent the SKV notification surface (#1887)
* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface

The company_members -> profiles!inner(email) PostgREST embed has no FK to
traverse (company_members.user_id references auth.users), so it 400'd and
silently killed all four notification emails since they shipped. Recipient
lookup is now a shared two-step helper (lib/notifications/member-email):
kvittens confirmations, skattekonto drift alerts (tax-contact routing
preserved via the plural variant) and backup alerts deliver again. The
connection-expired email is deleted instead of fixed: with SKV's 65-minute
personal sessions it was one mail per connect (see DECISIONS.md); the event
and needs_reconsent flagging stay.

For MCP-first users the agent is the notification surface, so:
- SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is
  normal (~1h by SKV design), only a person can reconnect with BankID, do
  not retry until they confirm. Inline strings (declaration-status, read
  routes, v1 pitfalls, accounted-api skill) aligned.
- gnubok_get_agent_briefing gains an optional skatteverket_connection block
  (status/source/connected_at + directive message on needs_reconsent),
  emitted only when a connection or verified system grant exists, so agents
  warn the user at session start instead of failing mid-task. Payload bench
  ceiling bumped 59.95K -> 60.15K for the outputSchema contract.

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

* fix(skatteverket): drift email resolves recipients via service client; review fixes

The skeptic pass refuted the drift-email repair: skattekonto.drift_detected
is emitted only by the nightly cron, and the extension registry builds each
event handler a fresh ctx from the anonymous cookie client (or none at all
on cookieless requests), so RLS returned zero company_members rows and the
two-step lookup still resolved no recipient. The handler now builds its own
service-role client, the same documented pattern as the retired
connection-expired handler; drift tests exercise the handler without ctx,
matching the cron reality.

CodeRabbit findings: resolveMemberEmails pages both queries through
fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000
rows); the v1 vat-declarations pitfall and regenerated accounted-api docs
now name both auth paths (member BankID connection or verified ombud
grant); the briefing's system-before-user priority carries a cross-reference
to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc
states the service-role-client requirement (profiles RLS is own-row-only).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:09:20 +02:00
f929b4b1d2 feat(mcp): lazy authentication so a client can connect before an account exists (#1814 PR 2) (#1892)
* feat(mcp): lazy authentication so a client can connect before an account exists

Second PR of agent-first onboarding (#1814). A client with no token may
now initialize, list the default catalog and call the three documentation
tools (search_tools, list_skills, load_skill). Every other request keeps
the transport-level 401 + WWW-Authenticate, which is what Claude, Claude
Code and Codex turn into their Connect prompt; with #1855 the account is
created inside that prompt, so the first protected tool call is the whole
signup trigger.

- The JSON-RPC body is parsed before auth so the method and tool name can
  decide whether a token is required. A tokenless unparseable body keeps
  the old 401 answer.
- Anonymous callers get an 'anonymous' actor, an empty scope set, a
  not-connected variant of the initialize instructions, and the full
  default catalog from tools/list (the agent has to be able to name a
  protected tool to trigger the challenge).
- Anonymous traffic is rate-limited per truncated IP via checkRateLimit;
  truncateIp moves to lib/api/ip.ts so the MCP server can use it without
  importing the v1 wrapper (which pulls lib/init and would cycle).
- gnubok_list_skills is now company-independent and skips its two context
  lookups when there is no company (anonymous or not yet onboarded).

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

* fix(mcp): gnubok_list_skills keeps its company_id argument as an optional-company tool

Making list_skills company-independent (so anonymous callers can run it)
silently dropped its company_id argument: a multi-company user asking
for another company's skill list got the key default instead. Optional-
company tools now advertise company_id and resolve (membership-checked)
it when an authenticated caller names one; anonymous callers cannot.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:38:29 +02:00
a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

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

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

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

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00
2a33291c18 fix(mcp): bulk-book titles that say what is approved, reject unknown parameters (#1856)
Two reports from the 08-24 feedback sweep (seq 261545):

- The bulk-book queue title (Samlingsverifikation: 1 transaktioner
  2026-07-22) carried no amount, direction or counterparty; the CEO
  approving from a phone could not tell what he authorised. Titles now
  read: Samlingsverifikation -1 000,00 SEK 2026-05-12: NORDNET UTTAG
  (+1 till). Same per-tx text the categorize titles already carry;
  preview_data stays aggregate-only.
- gnubok_query_journal called with {query} instead of {text} silently
  returned the whole journal. tools/call now rejects unknown top-level
  parameters for every tool (all schemas declare additionalProperties:
  false) with a VALIDATION_ERROR that lists the valid keys. company_id
  stays tolerated everywhere. codedError is exported from
  company-routing for the dispatcher.

The copy fixes this branch originally carried (scope-honest
list_pending_operations, BFL 5 kap 6 § on create_voucher, bank-movement
only on categorize/bulk_book) landed independently in #1844 and were
dropped on rebase; no catalog token change remains.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:44:30 +02:00
dc92fb5c0c fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op (#1852)
* fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op

Feedback seq 261545 (deepCFO): approving a bulk_book_transactions op over
MCP returned BULK_BOOK_UNAUTHORIZED, yet the op vanished from /pending
with nothing booked; the user believed it had been approved.

Two defects:

1. The bulk_book_transactions RPC gates on auth.uid(), which is NULL on
   the cookieless service client every MCP approval runs on, so EVERY
   API-key approval of a samlingsverifikat was refused. New migration
   20260824170000 adds p_user_id, honored only for service_role callers
   (same gate as match_batch_allocate 20260817150000 and undo_sie_import);
   the executor passes the approving user, who is now also the actor
   stamped on the verifikat. pg-real test covers member/spoof/no-JWT/
   grants like the precedent.

2. The dispatcher consumed the op on ANY executor error other than 404/
   409. An authorization refusal happens before any side-effect and says
   nothing about the op, so 401/403 now release the claim back to
   'pending'. The executor maps RPC codes through the structured-error
   registry so 403/404/409 are distinguishable from 400. Every
   CommitResult carries operation_status (pending | committed | rejected
   | failed_partial), exposed on gnubok_approve_pending_operation, so
   agents stop inferring consumption from status 'failed'.

Catalog token ceiling 59.95K -> 60K per the documented ratchet protocol.

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

* fix(pending-ops): revoke anon explicitly on the service-actor bulk_book signature

Default privileges grant EXECUTE on new functions to anon; the pg-real
grants test (mirroring match_batch_allocate) caught it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:32:12 +02:00
ebbdf96b74 feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1)

Every class 1-2 account the bank and skattekonto adapters do not own now
appears on the Avstämning page under "Övriga balanskonton" with IB, movement
and UB through the balansdag, a system specification where one exists
(1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld)
and, for every other account, the balance the signer states from their
underlag at sign-off. Same three doors as before: dashboard routes, v1 API
and the MCP tools take manual:<BAS> keys and an external_balance.

The ledger side is computed per fiscal period via generateTrialBalance,
never as an all-history sum: year-end re-books every balance account in an
opening_balance verifikat, so an all-history sum counts a closed year twice.

A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever
the system already has an outside truth, so it can never hide a difference.

No migration: account_reconciliations already accepts manual:NNNN keys.

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

* chore(api-skill): regenerate banking reference for the sign-off external_balance field

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:23:14 +02:00
1e6e19afe9 feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872)
The residual door existed for the page and the v1 API (#1862) but not for
agents: an MCP client that found a 10 kr bank fee between a selection and its
verifikat had to hand the last step back to the user. gnubok_reconcile_residual
dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap /
direction / skattekonto refusals surface immediately), stages a
reconciliation_residual operation with the would-book verifikat as the
preview, and commitReconciliationResidual links and books on approval.

Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by
storno + unmatch); scope transactions:write like the v1 route. The op type
is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list
verified against the live prod constraint 2026-08-25), and the tool joins
the reconcile_month / close_period loadouts and the reconcile-month skill.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:43 +02:00
MattssonandClaude Fable 5 0a3cb1a31a fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances (#1863)
* fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances

Posted-only was the default, but storno bookkeeping keeps both the
reversed original and its posted storno on the account: one-leg sums are
never balances. A customer's agent summed posted-only lines over a
storno-heavy quarter, found phantom VAT residuals on 2614/2641/2645/2647
and asked support to revert correct books.

- default status 'all' (posted + reversed), the same inclusion rule as
  trial balance, GL and SIE export
- explicit 'posted'/'reversed' get status_filter_warning when the
  opposite leg exists in range (entry-level head count, advisory)
- unknown status values now throw instead of matching nothing
- sibling description trims fund the additions within the tools/list
  payload budget

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

* fix(mcp): make status_filter_warning claim strictly true, hint status:'posted' for tag preview

Review feedback (PR Agent + skeptics): the entry-level opposite-status
count cannot prove the line-filtered totals are wrong, so the warning
now says one-leg totals CAN differ from balances; verb agrees with a
count of one. tag_journal_lines preview hint tells agents to pass
status:'posted' now that query_journal defaults to 'all'. Description
trims keep the tools/list payload inside the budget guard.

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

* fix(mcp): state the status_filter_warning count scope in the message

CodeRabbit: the opposite-status count applies entry-level filters only,
so say so in the warning instead of letting an account-scoped caller
read the count as account-scoped. Line-level scoping declined as
documented in DECISIONS.md (it would re-run the full line fetch).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:52:32 +02:00