Commit Graph

843 Commits

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* ci: re-trigger checks for the rebased head

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:14:54 +02:00
Jakob Wennberg a8980a3a41 perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited
for the slowest worklist scan, and the request also paid a sequential
bank_file_imports read after the batch, a second scan of the suggested
matches (getWorklistCounts counted the same 200 rows the pane listed) and
an awaited stale-dismissal delete on the read path.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:15 +02:00
Jakob Wennberg d3869e6694 fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.

The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:34:27 +02:00
Jakob Wennberg f08fc2c274 fix(invoices): honour defer_invoice_booking on MCP, REST v1 and inbox convert (#1921)
The #967 "Registrera men bokför inte" setting was only respected by the
dashboard routes. Six other paths decided whether to post the issue-time
verifikat with `accounting_method === 'accrual'` alone, so a company that
had switched booking to the explicit Bokför step still got vouchers posted
at issue through MCP, the REST v1 API and the invoice-inbox convert route:

- lib/pending-operations/commit.ts: send_invoice, mark_invoice_sent,
  create_supplier_invoice_from_inbox executors
- app/api/v1/.../invoices/[id]/send and mark-sent (commit + dry-run preview)
- app/api/v1/.../supplier-invoices POST
- extensions/general/invoice-inbox convert

All of them now call booksInvoicesOnIssue() from lib/bookkeeping/booking-mode,
the helper the dashboard already uses, and select defer_invoice_booking where
the settings projection did not include it. Behaviour for accrual companies
without the flag and for kontantmetoden companies is unchanged.

Tests: one deferred-company case per door (8 new), verified to fail without
the fix. skills/accounted-api regenerated for the changed v1 descriptions.

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:18:57 +02:00
Jakob Wennberg 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
Mattsson 00e7ac92ae feat(support): attach images and PDFs to the in-app contact form
Add optional image and PDF attachments to the existing in-app support contact form, with client-side limits, server-side validation, and email delivery. Preserve the existing subject, rate-limit, analytics, and storage behavior.
2026-08-26 12:32:29 +02:00
Jakob Wennberg c93a97bb4e fix(invoices): force 0% VAT on recurring and bulk-created invoices when the company is not VAT registered (#1838)
Issue #1719: moms lands on an invoice even though momskrysset
(company_settings.vat_registered) is off. The web and v1 create/update
routes, the MCP commit, and the webshop route all zero every line via
buildInvoiceWriteData, but two paths insert invoices directly and never
consult vat_registered:

1. executeRecurringSchedule (cron + run-now): the schedule dialog
   defaults template lines to 25%, stores vat_rate with no gate, and the
   spawn falls back to the customer default (25% for Swedish customers)
   for null-rate lines. The generated invoice carried 25% output VAT and
   could be auto-emailed to the customer and booked against 2611.
2. POST /api/v1/.../invoices/bulk-create: same fallback, same direct
   insert.

Both now mirror buildInvoiceWriteData: when vat_registered is false,
every line is forced to 0% at spawn/create time, and the header lands as
treatment 'exempt' with moms_ruta and reverse_charge_text null.

Self-billed received invoices deliberately keep their stated VAT: the
counterparty issued that document, and the books must mirror it
(ML 16 kap 23 §). Credit notes keep mirroring the invoice they credit.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 09:35:31 +02:00
Jakob Wennberg 64119d30bc fix(bank): Swedish provider errors and a durable failure trail for bank connect attempts (#1841)
Issue #1716: a user stuck in Handelsbankens fullmakt step got a raw
provider token back (server_error, invalid_state) and support had nothing
to look at afterwards: the failed pending row is deleted by design, the
callback only logged to console (short retention), and event_log recorded
successes only. Diagnosis of the reported case: the failures were on the
bank's side (the corporate fullmakt requirement); both of the reporter's
companies connected successfully on 2026-08-12 with no code change on our
side in between, and the connections have been active and syncing since.

Changes:
- lib/errors/get-error-message.ts: getBankConnectionErrorMessage() maps
  PSD2 callback outcomes (access_denied, server_error,
  temporarily_unavailable, session expiry, plus the internal
  invalid_state, missing_parameters and invalid_code_format tokens) to
  Swedish user messages, appending the raw provider description so the
  underlying error is still surfaced.
- callback route: every bank_error redirect and the stored error_message
  now carry the mapped Swedish text; bank_error_code, bank_name and
  psu_type still flow so the settings page keeps its targeted guidance
  (Handelsbanken fullmakt steps included).
- New audit events bank_connection.consent_denied and
  bank_connection.finalize_failed are emitted on the two failure paths
  and persisted to event_log, so support can answer which attempt failed,
  with which provider error, on whose side, even after the row is gone.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 09:35:26 +02:00
Jakob Wennberg 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
Jakob Wennberg 0743717033 fix(salary): keep the payslip's Ackumulerat total from going stale (#1911)
* fix(salary): keep the payslip's Ackumulerat total from going stale

`salary_run_employees.ytd_*` (the "Ackumulerat {år}" block on the
lönespecifikation) was written once at calculation time and never
recomputed, from a query that only counted prior runs already in
`booked`. Preparing next month's run before the current one is booked
(entirely normal) therefore froze a YTD that is permanently missing the
month in between, and the employee's payslip understates the year.

Seen in production: an August run calculated on 2026-07-23, three days
before the July run was booked, shipped a payslip whose Ackumulerat brutto
was 60 000 kr instead of 95 000 kr.

Two fixes, both in the new lib/salary/ytd.ts:

- `computePriorYtd` counts `approved`, `paid` and `booked` prior runs, not
  only `booked`. `corrected` stays excluded: its correction run replaces
  the whole month, so counting both would double it.
- `refreshRunYtd` recomputes and rewrites the snapshot, and is now called
  at approval (the first status lönebesked can be sent from) and at
  booking, on both the dashboard and v1 surfaces. Rows already correct are
  left untouched; a failure is logged and never blocks an approval or a
  booking.

The snapshot stays a snapshot rather than becoming a render-time sum: an
employee re-opening a lönebesked must see the figures it had when it was
issued. YTD is display and reporting only, so nothing here can move a
verifikation: the per-month tax lookup and the avgifter caps never read it.

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

* fix(salary): fail loudly on a YTD read error and paginate the reads

Review follow-up on both counts:

- The opening-balance and prior-run reads discarded their `error`. A failed
  read looked exactly like a month with no prior pay, so `refreshRunYtd`
  would rewrite the snapshot to the current month alone and still report
  success. Both now throw; `refreshRunYtd` turns that into `ok: false` for
  its callers to log, and `runSalaryCalculation` returns DATABASE_ERROR the
  way it already does for every other query error in that function.
- The prior-run and roster reads now page through `fetchAllRows()` ordered
  on the primary key. A full roster times eleven prior months passes
  PostgREST's 1000-row cap well before an employer is large by Swedish
  standards, and a silent truncation there understates somebody's
  Ackumulerat.

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

* refactor(salary): one paginated loader for cutover opening balances

Review follow-up. `run-calculation` and `ytd` each read
employee_opening_balances with their own unpaginated, error-discarding
query. Both now go through `loadOpeningBalances()`: paged via
fetchAllRows() ordered on the primary key, and throwing on a read error.

The error path matters more than the paging one here. That row carries
`karens_periods_adjustment` as well as the YTD carry-in, and a discarded
error looked exactly like "nobody has a cutover balance" - which would
drop a karensavdrag from sjuklön silently, not just understate a display
figure. runSalaryCalculation now maps it to DATABASE_ERROR.

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-25 21:40:14 +02:00
Mattsson 638a25a11a fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap (#1910)
* fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap

The payout file is whole kronor and skattereduktionen is capped at a
share of the work price (HUSFL: 50% RUT / 30% ROT). Math.round pushed an
exact half-krona deduction up: 125 kr work -> 62,50 kr RUT became
begart 63 with betalt 125 - 63 = 62, so the DEDUCTION_EXCEEDS_PAYMENT
guard blocked a perfectly correct invoice. Every work price that is an
odd number of kronor hits this.

BegartBelopp now floors the ore-rounded sum: 62,50 -> 62, betalt 63,
valid file. Flooring can never create begart > betalt (2*floor(D) is an
integer <= pris <= round(pris) whenever the ledger deduction respects
the cap), so the guard becomes a pure corruption check. Invoices that
pass today are unchanged: round and floor only differ at fraction >= .5,
and those were all blocked. The ore-rounding before the floor keeps
float noise (62.499999...) from dropping a whole krona; a test pins it.

Regression tests cover the 125-kr half-krona case end to end (evaluate,
XML amounts, eligible list).

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

* refactor(rot-rut): use roundOre helper for the pre-floor ore rounding

check:guards naive-ore-round flags inline Math.round(x*100)/100; the
sanctioned @/lib/money roundOre does the same with an EPSILON nudge.

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

* fix(rot-rut): pin the ROT half-ore class and use truncateToWholeKronor

Skeptic findings on the frozen head (fbc3bb0c8):

1. The 'previously-passing invoices are byte-identical' claim was false
   for ROT: at 30% the deduction sits far below the begart > betalt
   guard, so e.g. 500 kr labor + 125 moms (ROT 187,50) previously
   PASSED and emitted 188, exceeding both the statutory cap and the
   1513 fordran. The floor now emits 187/438: deliberately 1 kr lower.
   A test pins the case, and DECISIONS.md states the real blast radius.

2. lib/money.ts already ships truncateToWholeKronor, documented as the
   amount rule for everything Skatteverket-bound, with the same
   ore-round-then-truncate semantics; use it instead of hand-rolling
   Math.floor(roundOre(...)).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:32:06 +02:00
Mattsson d035d283ef feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep (#1908)
* feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep

The bulk sweep hardcoded the revenue side to the standard 3001-series, so
a store selling both goods and services could not route tjansteordrar to
its own revenue accounts (user request, follow-up to #1900). The bulk
dialog now has a "bokforingsmall" section: per-VAT-rate revenue account
inputs, shown only for rates present in the selection, prefilled with the
effective defaults; only diffs from the default map are sent.

Server side, BulkBookWebshopOrdersSchema gains an optional
revenue_accounts map (class 3 accounts only) that buildOrderBookingLines
routes each rate bucket's revenue line through; output VAT accounts stay
derived from the rate and are not overridable. User-chosen accounts are
never auto-created: the route verifies them against the company chart up
front and aborts the whole sweep with
WEBSHOP_ORDER_REVENUE_ACCOUNT_UNKNOWN naming the offenders, while
accounts in the closed prefill set keep riding the existing chart
repair. No hardcoded varor/tjanster preset on purpose: BAS 2026 has no
standard 30xx goods/services subdivision (see DECISIONS.md).

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

* fix(woo): harden the bulk revenue template per skeptic and review findings

Three findings from the adversarial review of the revenue-template
commit, fixed in one pass:

- Build breaker: revenueAccountByRate was typed Partial<Record<...>>,
  making Object.values() return (string | undefined)[] and failing the
  production build's type-check (Vitest and ESLint both miss it). Typed
  as Record<number, string>; only truthy strings are ever inserted.

- 3740 template collision (two skeptics, independently): choosing 3740
  as a revenue account passed the class-3 gate, skipped the chart guard
  (it is in the closed prefill set), and made the residual bound read
  the templated revenue line instead of the residual, so a mangled
  gift-card order the sweep must refuse could book a ~499 kr gap as
  "oresavrundning" in an immutable verifikat. 3740 is now banned by the
  schema and the dialog mirror, and the residual line is identified
  structurally (always the last line) instead of by account lookup,
  which also fixes the pre-existing misdiagnosis when 3740 is used as
  payment_account.

- Rate-classification guard (Swedish accounting review): output VAT
  books 2611/2621/2631 per rate regardless of template, but a custom
  account counts toward ruta 05 only when configured for that rate
  (explicit momssats, rate-mapped treatment, or rate-conforming
  30x1/2/3 number + name, i.e. exactly inferDomesticSalesRate, now
  exported and reused). A mismatched pair is refused up front with
  WEBSHOP_ORDER_REVENUE_ACCOUNT_RATE_MISMATCH naming the offenders;
  default-set accounts are valid only for the rate they are the default
  for; rate-0 buckets are exempt (no output VAT, legitimate momsfri/
  export accounts).

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

* fix(woo): explicit momssats wins over name inference in the revenue-template guard

Two Swedish accounting review findings on the rate-classification guard:

- Precedence: the OR check let number+name inference qualify an account
  whose explicit default_vat_rate says a DIFFERENT rate (6%-configured
  account passing a 25% slot on its name). The guard now resolves ONE
  effective rate exactly like fetchDynamicVatAccounts does (explicit
  momssats, then rate-mapped treatment, inference only when nothing is
  configured) and compares that.

- Rate 0 slots no longer skip the check entirely: an account whose
  resolved rate is TAXABLE contradicts the 0% bucket and is refused,
  while unconfigured momsfri/export/EU accounts stay accepted (no
  contradicting configuration required, not positive proof of 0%).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:04:06 +02:00
Mattsson 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
Mattsson a83baede72 fix: block staging backend on production white-label hosts (#1903)
* fix: block staging backend on production brands

* fix: clarify production domain classification

* fix: alert on forbidden white-label backend
2026-08-25 19:55:53 +02:00
Mattsson 328ccda10d fix: protect public Auth flows from automated abuse (#1904)
* fix: add Turnstile to public auth flows

* test: isolate Turnstile auth tests
2026-08-25 19:14:06 +02:00
Mattsson c634430677 feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep

Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox
column, a bulkbar with select-all/clear, and a confirm dialog that books
every selected order with the standard order template (per-store payment-
method mapping, optionally one override account for the whole selection).

Server side, POST /api/webshop-orders/bulk-book books each order as its
OWN verifikat through the exact same flow as the single-order endpoint:
the guards, FX retry and race-free draft -> claim -> commit sequence are
extracted to lib/webshop-orders/book-order.ts and shared by both routes,
so nothing added to the single path can miss the bulk path. Partial
failure is reported per order and never aborts the batch.

Fixes #1880

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

* fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe

The account-group key template literal picked up a raw 0x00 byte during
generation (known escape-mangling hazard), making git treat the file as
binary. Same grouping semantics, plain '|' separator.

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

* fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings)

The sweep has no reviewing user, so everything the single dialog relies
on a human to catch is now refused per order or aborted:

- empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed
  sale classified as 12%, refunds reversing zero moms via 3004) is only
  allowed as the single dialog's editable prefill; bulk refuses with
  WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING
- invoice-mode payment methods: booking would foreclose Skapa faktura
  and post a wrong clearing leg; refused with
  WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not
  bypass the merchant's configured flow)
- 3740 residual above ore scale (gift-card gaps booked as
  'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE
- settings-fetch failure now aborts the sweep instead of silently
  rebooking every order to 1686 against the confirmed dialog
- maxDuration 300 so a platform kill cannot strand an order between
  claim and commit
- per-order guard details (e.g. journal_entry_id) survive into the
  failure envelope

The dialog mirrors the skip rules up front (named order numbers, not an
anonymous count) so the confirmation describes exactly what will book.

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

* fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep

A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown
gate with zero residual, but the rate-to-account maps would fall back to
the 25% accounts and book foreign VAT as Swedish utgaende moms 2611
(skeptic finding). The sweep now refuses such orders per order with
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending
rates); the dialog mirrors the rule and names the skipped orders. Only
the single dialog may show that prefill, as an editable guess.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:34:34 +02:00
Mattsson 5fc0be9ed7 feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking

Booked webshop orders only carried the VAT split; the verifikat showed no
product lines, customer or payment method although the sync already stores
all of it in webshop_orders.line_items (#1881).

- lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf
  template (order lines, customer, payment method, per-rate VAT summary,
  SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and
  archives the PDF on the committed verifikat through uploadDocument
  (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf.
  Never throws: the booking is immutable by then.
- book route: archive after commitEntry; response gains underlag_archived.
  FX-retry now also syncs the in-memory row so the underlag shows the
  resolved SEK facts.
- webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration
  20260825140000) to the verifikat_without_documents needs-doc list, so a
  failed attach or a historical booking surfaces on the saknar-underlag
  worklist. transactions_without_documents is deliberately unchanged.
- tests: underlag model/render/archive unit tests, book-route archive and
  failure-isolation cases, pg test extended (per-source-type probe now
  covers webshop_order; explicit flagged/silenced pair).

Fixes #1881

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

* chore(migrations): move webshop needs-doc migration after main's 20260825150000

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

* test(webshop): add manually_booked fields to the underlag order fixture

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

* fix(webshop): skeptic findings on the orderunderlag (#1881)

Two refutations from the skeptic pass on PR #1899, both fixed:

1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which
   Helvetica/WinAnsi PDF fonts drop silently, so refund and discount
   amounts on the archived underlag rendered as POSITIVE. formatAmount now
   replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency),
   is exported, and is pinned by a regression test.

2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed
   webshop_order, so flagged rows rendered without the "Underlag saknas"
   chip, waiver toggle, or batch-exempt selection, and the weekly
   missing-underlag push cron disagreed with the badge. The constant now
   lives in dependency-free lib/worklist/types.ts (client-safe), is
   re-exported from categories.ts, and both JournalEntryList.tsx and
   push-notifications/notification-scheduler.ts consume it instead of
   their own copies.

Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic
observation: the dialog's lines are user-editable, so the underlag must
state the order's conversion, not claim a booking fact).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:15:33 +02:00
Mattsson 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
Mattsson 79013cf092 feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883)

Two deliverables from the community report where a bad SIE test import
left no way out short of deleting the company:

A) Discoverability: the voucher list shows one attn line linking to
   /import?history=sie whenever the page contains import-sourced
   vouchers, and /import?history=sie deep-links straight into the
   fold-open SIE import history where per-import Angra already lives.

B) Reset of an UNLOCKED fiscal year regardless of how the entries
   arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape
   hatch as undo_sie_import; no enforcement trigger touched) behind
   GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed
   type-the-year-name confirmation dialog on the fiscal years settings
   list. Refuses on: locked/closed year, company lock date over any part
   of the year, executed year-end, arsredovisning state, later year
   depending on this year's UB, VAT-declared evidence (vat_settlement
   verifikat, SKV lock/submit audit rows, extension workflow keys, fail
   closed) and AGI-declared months. Entries referenced by RESTRICT/NO
   ACTION FKs abort the whole reset (all-or-nothing). Documents are
   detached, never deleted (BFL 7 kap); every delete is audit-logged
   plus one behandlingshistorik summary row.

Fixes #1883

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

* fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883)

Blocking skeptic findings on PR #1897, one consolidated pass:

- New snapshot blocker cross_year_reference: an entry outside the year whose
  correction_of_id / reverses_id / reversed_by_id points into the year made
  the delete crash with an uncaught P0001 (immutability trigger refusing the
  ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and
  silently severed draft chains. 12 such chains exist in prod today.
- New snapshot blocker rot_rut_state: a begaran om utbetalning that reached
  Skatteverket (submitted/paid/partially_paid/rejected) was silently
  unlinked via SET NULL, erasing the bokforing behind a filed and possibly
  decided myndighetsarende.
- Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit
  rows carry no company_id and header rows no amounts, so a reset destroyed
  konton/belopp with no company-readable trace. The RPC now archives the
  full content of every verifikat in company-scoped RESET_SNAPSHOT audit
  rows before deleting (action added to audit_log_action_check, NOT VALID),
  and behandlingshistorik renders them.
- Dimension registry lockstep on reset (mirrors undo_sie_import): flipped
  imports can never be undone again, so their dimensions/values would have
  been orphaned forever.
- EXCEPTION WHEN raise_exception now returns a typed
  FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500;
  gnubok.allow_delete is cleared before leaving the guarded block.
- Voucher-list attn line fires only for source_type 'import':
  opening_balance is also written by year-end closing and the manual IB
  flows, which mislabelled every year-2+ company as SIE-imported.
- /import?history=sie now scrolls the SIE history into view.
- Reset dialog copy (sv+en) discloses that linked invoices, payments and
  bank transactions become unbooked; new blocker strings in both locales.
- pg fixture fix: document_attachments seeded without company_id (23502);
  new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:36:18 +02:00
Mattsson 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
Jakob Wennberg d80103a2f5 fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed (#1888)
* fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed

Two reports from the same salary run (Fabian, Specific AI Sweden AB).

1. The payment file carried an OCR Skatteverket does not accept.
   generateSkattekontoOcr built the reference from the TEN-digit org number
   plus a Luhn check digit (11 digits). Skatteverket's reference is the
   TWELVE-digit identity plus a check digit: an organisationsnummer carries
   the "16" prefix, a personnummer its century. For 559547-0021 we emitted
   55954700211 where Skatteverket prints 1655954700217.

   The twelve-digit form is the same "redovisare" identity the AGI and moms
   APIs take, so it now goes through the shared toRedovisare12 converter
   instead of a second local rule: the payment file and the declaration it
   pays must not disagree about who the taxpayer is. That needs the entity
   type, which the route now reads alongside org_number.

   The route also prefers saldo.ocrNummer from the cached skattekonto
   snapshot over the derived value. It is Skatteverket's own answer for the
   account we actually sync, it covers identities the converter has no rule
   for (samordningsnummer, GD-nummer), and it covers the companies whose
   companies.org_number has drifted from company_settings.org_number.

2. AGI status stayed on "väntar på BankID-signatur i Mina Sidor" after the
   user had signed.
   Reading the kvittens needs a live Skatteverket session, and the personal
   token lives ~65 minutes, so by the time anyone signs in Mina Sidor the
   2-hourly kvittens cron finds a dead token and skips quietly. The panel
   kept asserting a state it could no longer observe.

   It now says so instead, and the reconnect action already on the panel is
   the fix: runPostConnectRefresh reconciles pending declarations on a fresh
   consent. sessionExpiredStatus also counts the needs_reconsent health flag,
   which a cron can set while the access token is still inside its hour;
   without it the panel reported a dead connection as healthy.

   Background reconciliation without a reconnect needs the läsombud grant,
   which is a registration decision and not part of this change.

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

* docs(skatteverket): say why the entity_type collapse in the payment-file route is total

companies.entity_type is NOT NULL with CHECK IN ('enskild_firma',
'aktiebolag'), so the ternary cannot silently mis-tag an enskild firma as
a legal entity and give a personnummer the "16" prefix. Two review bots
read it as an unguarded default; write down the constraint that makes it
safe instead of leaving the next reader to re-derive it.

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

* docs(decisions): record why the cached skattekonto OCR needs no freshness gate

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-25 14:25:00 +02:00
Mattsson cbfb2201ff fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)
* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884)

Four silent drop paths made a paid RUT invoice invisible in the begaran
dialog (neither eligible nor blocked), and the empty list hid the year
picker so the dialog looked dead:

1. deduction lines without a header deduction_total: a second line-based
   candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING
   (also at file generation: the 1513 receivable was never booked).
2. partially_paid with the customer share settled: remaining_amount = 0
   (total - paid_amount - deduction_total, migration 20260817191708) now
   counts as paid in evaluateInvoiceForFile; a genuine partial blocks as
   NOT_PAID with the outstanding amount.
3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message
   points at the other type, and the dialog's empty state adds a
   switch-type hint.
4. invoices held by a generated/submitted begaran block as
   ALREADY_REQUESTED naming the request; decided requests stay omitted
   (finished business, visible in the history list).

The dialog keeps the year picker rendered when the list is empty (current
year as inert fallback) and opens the blocked list by default when nothing
is eligible.

Fixes #1884

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

* fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884)

Two skeptic refutations against the frozen PR head:

1. Regression: the wrong-type branch ran before the active-request lookup,
   so invoices of the OTHER type whose begaran was already decided
   resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked
   list, and the empty-state hint pointed at a tab where they never appear.
   The decided-request skip now runs first, on every tab.

2. Correctness: the paid gate and the NOT_PAID message trusted
   remaining_amount, but payment-sync's storno path recomputes it WITHOUT
   subtracting deduction_total, so the stored column can carry Skatteverkets
   1513 share and the dialog could assert a wrong customer-outstanding
   figure. The gate now derives the customer share as
   total - paid_amount - deduction_total (the buildInvoiceWriteData /
   migration 20260817191708 formula) from fields every settlement path
   maintains.

Tests pin both: decided+wrong-type omitted from both lists, corrupted
remaining still classified and reported from the derived share.

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

* docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884)

The skeptic-hardening commit moved the paid gate off remaining_amount to
the derived customer share (total - paid_amount - deduction_total); the
comment still named remaining_amount as the signal.

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

* fix(rot-rut): explicit decided-status set + correction-path wording (#1884)

Swedish accounting review findings on the candidate list:

1. The decided-begaran skip inferred 'decided' by exclusion (anything not
   generated/submitted), so a future request status would make an invoice
   vanish from both lists, exactly the silent drop the module forbids.
   DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other
   status held by a request lands in blocked as ALREADY_REQUESTED with a
   generic message. Test pins it.

2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which
   could read as an invitation to edit a booked invoice directly. The
   invoice edit route already refuses sent/paid/booked invoices, and the
   message now names the sanctioned path: drafts edit directly, sent or
   paid invoices are corrected via credit note + new invoice.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:24:01 +02:00
Mattsson 0bb482bf6e feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader

Proposal views (AI suggestion, static template, counterparty template with
or without a line pattern) previously offered only accept-or-start-over: the
verifikation preview was pure rendering and the only line-editable path was
library templates. This adds an "Andra rader" affordance to the proposal
view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK
amounts, VAT legs, exactly what the preview shows) into
TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing
the same initialLines mechanism library templates already use.

- lib/bookkeeping/proposal-lines.ts: line computation extracted from
  JournalEntryPreview into computeProposalLines() (single source for preview
  and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping
  to the JournalEntryForm prefill shape. The settlement leg is flagged so
  the booking dialog swaps in the transaction's resolved cash account and
  stamps currency metadata, mirroring buildInitialLinesFromTemplate.
- JournalEntryPreview now renders computeProposalLines() output unchanged.
- TransactionBookingDialog accepts proposalLines (takes precedence over
  preselectedTemplate); the booking still goes through JournalEntryForm's
  normal manual validation and the engine, no validation bypassed.
- Ore rounding funnels through roundOre(); guard baseline ratcheted down.
- New strings in messages/sv.json and messages/en.json (tx_quick_review).
- Unit tests for all three proposal branches incl. VAT legs, reverse
  charge, multi-line patterns, 3740 rounding diff and FX metadata.

Fixes #1878

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

* fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings)

Three skeptics refuted the first cut of #1878: the extracted preview math
was a lossy approximation of the engine, and making it bookable made every
loss a real booking defect. This commit closes each refuted scenario by
mirroring the exact engine path per proposal branch:

- Balance: VAT is single-rounded and the net leg is gross minus that VAT
  (transaction-entries.ts semantics). Independently rounded net+VAT went
  off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94),
  prefillling an unbookable verifikat.
- 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via
  resolveExplicitVat before computing lines, so an explicit no-VAT choice
  prefills no VAT line instead of re-deriving the 25% category default
  into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments).
- Ore parity: engineRound (plain Math.round(x*100)/100, matching the
  engine) replaces roundOre where the engine is naive; roundOre kept only
  where the engine uses it (category VAT leg). No more 1-ore drift between
  preview, prefill and the booked verifikat (8.62 RC, 34.30@12%).
- Legacy counterparty pairs: new counterpartyLegacy mode mirrors the
  legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair
  (previously dropped: an RC expense would have booked without fiktiv
  moms, understating rutor 30/48), VAT on expenses only, income gross, and
  sign-mismatched matches mirrored like buildLegacyMismatchResult.
- Pattern mirror: sign-mismatched line patterns flip learned sides like
  buildMultiLineMappingResult; ratio allocation filters business/tax types.
- Entity accounts: static template accounts resolve debit/credit_account_ab
  for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer
  previews or books EF-only accounts like 2013.
- Settlement swap: only a literal-1930 settlement leg is swapped to the
  resolved cash account (applySettlementAccount parity); learned non-1930
  money legs (1510/2440/2890/19xx) stay authoritative.
- FX: QuickReviewDialog hands its enriched transaction row to the booking
  dialog so the settlement leg's exchange_rate metadata matches the rate
  the SEK amounts were computed with.

34 unit tests incl. every skeptic counterexample; guard baseline ratcheted
to 622 (below main's 626).

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

* fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation)

Two independent skeptics refuted the pattern branch: the engine books the
money leg on the counterparty template's learned legacy account (credit
for an expense, debit for an income, mirror-swapped, falling back to
1930), while the preview/prefill defaulted to 1930. A SIE-learned
pattern settling on 2440 showed kredit 1930 in the preview but booked
kredit 2440 on confirm. QuickReviewDialog now passes the learned pair
raw (no entity resolution, engine parity) and computeProposalLines
selects the settlement account exactly like buildTransactionEntryLines;
the literal-1930 swap to the resolved cash account is unchanged.

CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740
rounding line keeps the engine's business-side placement for both diff
signs (parity contract; an unbalanced set is rejected at commit), and
the naiveOreRound baseline stays at 622 (engineRound is a documented
parity exception).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:58 +02:00
Mattsson 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

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

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

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

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

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

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

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:56 +02:00
Mattsson cd46d936f3 fix(bokforing): account search results in the booking dialog search field (#1877) (#1889)
* fix(bokforing): account search results in the booking dialog search field (#1877)

The search field in the "Bokfor transaktion" dialog only matched template
metadata, so typing an account number or name (e.g. active 5460
Forbrukningsmaterial) gave zero hits and no path to booking; the real
account search was only reachable via the discreet "Bokfor manuellt" link.

- TemplatePicker now also searches the company's active chart of accounts
  (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton"
  result group; picking one routes into the same manual booking flow as
  "Bokfor manuellt" with the account prefilled on the counter line.
- New buildActiveAccountIndex helper indexes chart rows active-only, so
  deactivated accounts never surface as bookable results.
- searchTemplates additionally prefix-matches all-digit tokens against a
  template's business account (debit for expense, credit for income, both
  legs for transfers, AB variants included); the settlement leg is
  deliberately excluded so "1930" does not light up every template.
- Library template search prefix-matches line accounts on all-digit queries.
- Empty search results show a hint pointing at "Bokfor manuellt" for
  accounts outside the active chart; placeholder now says "Sok mall eller
  konto...". New strings in both messages/sv.json and messages/en.json.

Fixes #1877

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

* fix(bokforing): match only business lines when digit-searching library templates (#1877)

CodeRabbit review on PR #1889: the all-digit library-template match also hit
settlement and VAT lines, so searching "1930" lit up every user template with
a bank settlement leg. Restrict the predicate to business lines, mirroring
the static catalog's settlement-leg exclusion.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 13:30:03 +02:00
Jakob Wennberg 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
Mattsson 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
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg e35714518f fix(year-end): never seed opening balances into a non-adjacent period (#1849)
Feedback seq 249297: run_year_end on Räkenskapsår 2024/2025 seeded the
closing balances into an existing 2026/2027 period and left no period
at all for 2025/2026. Root cause: SIE import wires previous_period_id
to the NEAREST later period regardless of gap (the company had an
onboarding-seeded 2026/2027 when 2024/2025 was imported), and
findNextPeriod trusted the chain unchecked.

- findNextPeriod: a chained period is only the next period when it
  starts the day after the current one; otherwise log and fall through
  to the date lookup so year-end creates the contiguous period.
- createNextPeriod: relink a successor that was chained across the gap
  onto the newly created period, healing the chain.
- SIE import: wire predecessor and successor links only when
  date-adjacent. A gap stays unlinked until the missing year exists.

Prod scan 2026-08-24: 40 non-adjacent links across 39 companies (mostly
an old historical year chained to an onboarding-seeded current year).
The read-side guard neutralizes all of them for year-end; the data
repair is a separate, founder-approved step.

The reporting company (23dc3c97) self-repaired the same evening via the
fiscal-periods gap-fill route; the stray IB entry is reversed.


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:25:58 +02:00
Jakob Wennberg 3dce94d39e fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut, bilagor reminder (#1875)
* fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll

A year closed in a previous system (Klarmarkera perioden) has no closing
verifikat in these books by definition, so the statutory pre-closing guard
in the trial balance has nothing to strip; it now lets that case through
instead of failing the whole Årsredovisning page with a 500.

Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for
the bilagor inside the bokslut module, not under Rapporter), and the
wizard's Kontroll step says how many balance accounts are still unsigned
per balansdagen with a link to the pärm.

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

* fix(nav): admit the bokslutsbilagor label key

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

* feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint

Twenty-odd balance accounts pushed the bank rows out of view on a migrated
company. The group opens when a manual account is selected and otherwise
remembers the last choice per browser.

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 10:08:46 +02:00
Jakob Wennberg 9ce1ebc65f feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

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

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

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

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

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:35:02 +02:00
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

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

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

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

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

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

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

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>

---------

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:34:05 +02:00
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 9ebb2e518f feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)
The old Bankavstämning report page was the only place a user could still tag
a bank row as ingående balans or move it to another bank account, so the new
/reconciliation page kept linking out to it and the reconciliation lived in
two places. Both row tools now live on the account overview (hover-revealed,
same endpoints), the ?autorun=1 deep link from the transactions inbox runs the
matcher on the new page, and the report slug redirects: old links, ⌘K, the
bokslut readiness wizard and the ignore toast all land on /reconciliation.

BankReconciliationView and its FocusedReport branches (own range preset,
help popover, autoRun plumbing) are removed; the source-grepping parity test
for its quick-book path goes with it.

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:17 +02:00
Jakob Wennberg d88df74b85 feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:30:54 +02:00
Jakob Wennberg 3edbf0a2e3 fix(agent): chat console keeps its thread across turns and reloads (#1859)
Three user-reported failures in the assistant panel, one root cause each:

1. "The chat asks what I'm referring to" when continuing a thread. The
   single-call console (general.help, AskConsole -> /api/agent/ask) was
   stateless since the 08-20 model-agnostic cutover: conversationId was only
   the tool actor id, so every turn was answered blind, reload or not.
   The provider-agnostic GenerateTextRequest gains an optional `history`
   (real message turns before the prompt, in both the Anthropic-family and
   the OpenAI-compatible adapter; absent/empty leaves the request
   byte-identical to the single-turn call). The route loads the thread's
   earlier turns server-side (loadChatHistory: text only, hidden and tool
   rows dropped, alternation repaired, newest 16 rows / 10k chars) before
   writing the new question, and hands them to the model.

2. A full page reload (the deploy prompt's "Ladda om") closed the docked
   panel and dropped the thread from view. The panel now remembers its open
   thread per tab in sessionStorage (lib/agent-panel/session-restore) and
   the provider reopens it on mount; the sheet loads it exactly like a pick
   from "Tidigare konversationer". Close and "Ny konversation" forget it; a
   thread that no longer opens is dropped instead of retried on every reload.

3. "Can't type any more" once the update banner shows. DeployReloadPrompt's
   full-width wrapper sits at z-[60] after the panel in DOM order and
   swallowed clicks on the panel's composer; only the card takes input now.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:37:30 +02:00
Mattsson f75ea2384d feat(calendar): enable calendar sync for Viktiga datum (#1853)
Turns on the calendar extension (built Feb 2026, stripped in the 2026-03-02 production readiness deploy, never re-enabled): ICS feed settings, calendar workspace, subscribe button on the Viktiga datum page.

Hardening before first real use: feed serve route now requires the creator to still be a company member (offboarding stops the feed); stable pagination (due_date + id, dedupe) on feed queries; fetches inside the logged try block; invoice events limited to sent/paid/partially_paid/overdue; event UIDs rebranded to accounted.se while zero feeds exist; APP_URL fallback fails closed in production; mobile stacking for the deadlines header; settings note that Google Calendar needs default notifications on subscribed calendars; calendar workspace aligned with the design system.

Skeptic reviewed (3 refutations, all fixed) plus one compliance swarm finding (fixed). No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:32:44 +02:00