150e2a3f1485bfe4e8a830dc4fa63ece4c763690
418 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
150e2a3f14 |
feat(reconciliation): agent surfaces, skattekonto notice, bank icons and fair sync order (#1836)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) * feat(reconciliation): agent surfaces: summary resource, attention category, reconcile-month skill, skattekonto notice, fair sync order Accounted://reconciliation/summary: every reconcilable account with its state, unexplained difference, open counts, last fetch and latest sign-off, plus a next step; the rail as a resource, on the same service function the page and v1 use. Accounted://attention gains reconciliation_due (shared predicate with the Hem row). A reconcile-month workflow skill and the reconcile_month loadout describe the account-keyed flow (summary -> bridge -> buckets -> sign-off). The skattekonto sync persists its reconciliation summary (skattekonto_reconciliation_latest) so the new Hem notice skv_unexplained ("Skattekontot stämmer inte med bokföringen: X är oförklarat", link to /reconciliation?account=skattekonto) costs one small read instead of a bridge computation per render; it honours the drift tolerance and its id carries the whole-krona amount so öre noise never resurfaces a dismissal. The skattekonto sync cron orders eligible companies by stalest sync (never-synced first) before its per-run cap, so the tail is no longer starved by a fixed order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger preview build (builder OOM during Running TypeScript, not the diff) * fix(reconciliation): visual pass round 1: full-width table, bank tile shows the period sum From Jakob's first look at the page on real data: - The items table now spans the full page width (the approved layout); the rail + tiles + bridge + actions stay in the two-column grid above it, which now lives inside AccountOverview (the rail rides in as a prop) so the table can break out below. - The bank account's first tile said "okänt": it read external_balance (the reported bank balance, often unknown) while its label says Banktransaktioner i perioden. It now shows the bridge's period sum, matching the label, the difference and the bridge line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): bank brand icons in the rail The rail resolves each bank account's icon from its connection's bank_name (falling back to the account name) against square brand icons committed under public/logos/banks/: the set covers every bank with a live connection in prod as of 2026-08-24 (SEB, Lunar, Handelsbanken, Swedbank, Nordea, Svea, Länsförsäkringar, Revolut, Wise, Danske, Klarna, Northmill, PayPal, plus Stripe for named accounts). Word-boundary matching so lookalike names never hijack a logo; anything unmatched (the small sparbanker, file imports) keeps the monogram. The skattekonto already had its Skatteverket mark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): label the bank period sum as netto Jakob read 'Banktransaktioner i perioden 399 941 kr' as gross activity (his is ~1,9 MSEK) and rightly asked why it was so low: the value is the net movement (in - out), which is what the bridge compares against the net booked movement on the ledger account. Verified against raw prod data (237 rows, 1 169 126,40 in, -769 185,04 out = 399 941,36). The tile and the bridge line now say '(netto)' / '(net)'. 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> |
||
|
|
f40795896f |
feat(reconciliation): sign-off, period picker, Hem row and the three doors for it (#1835)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3a62c5419e |
feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools (#1833)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. 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> |
||
|
|
0a8544e0cb |
feat(reconciliation): account-keyed engine: one bridge for bank and skattekonto (#1813)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. 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> |
||
|
|
7cf15a105f |
fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> |
||
|
|
2fd58c4125 |
feat(pending): queue order toggle, entry date + notes in review, account names everywhere (#1812)
* feat(pending): queue order toggle, entry date + notes in review, account names everywhere Four review-queue gaps reported by a customer approving bokslut batches: - Oldest-first toggle: /api/pending-operations accepts order=asc|desc (default desc); the queue header gets an Äldst först / Nyast först button, remembered per browser (localStorage pending.sortOrder). - Fiscal year visible: categorize previews now carry the transaction date (preview_data.date) and render a Datum row, so two open years are distinguishable. - The agent's `notes` (audit-trail context) is shown in the detail panel as Anteckning; before, it was stored in params and never rendered. - Account names: VoucherLinesTable and PreviewKonteringTable fall back to the chart name from AccountNamesContext (6110 Kontorsmateriel · AMAZON PRIME instead of the bank text alone); useAccountNamesSource moves to a shared hook so the chat ApprovalCard provides the same names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): call useAccountNamesSource in ApprovalCard The provider referenced accountNames without the hook call; the core build (tsc) caught it. Local tsc had not, so this also re-runs the full check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d35c401c0c |
fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message (#1811)
* fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message gnubok_reverse_journal_entry / gnubok_undo_sie_import cap `reason` at 500 characters, but exceeding it produced code UNKNOWN_ERROR with message_sv "Något gick fel. Försök igen." while the cause sat only in message_en. getStructuredError now infers VALIDATION_ERROR from the message and getErrorMessage maps it to "Motiveringen får vara högst 500 tecken.". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(errors): pin the reason-length pattern to 500 and assert the envelope on undo_sie_import Review findings: the Swedish message hard-codes 500, so the pattern must match that limit only; the undo_sie_import 501-char test now asserts code and both localized messages like the reverse test does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
158ef0f484 |
feat(mcp): gnubok_list_cash_accounts, a search-only discovery tool for bank accounts (#1810)
Follow-up to #1809: transaction listings now carry cash_account_id, but an agent had no way to learn which cash accounts exist or which BAS ledger each maps to. The tool lists cash_accounts (cash_account_id, ledger_account, name, currency, iban, is_primary, enabled, source), optionally enabled only. Search-only (catalogVisibility 'search') so tools/list stays inside its context budget; gnubok_search_tools finds it on "bank account"/"cash account". Scope transactions:read. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6e5694fd03 |
feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it (#1809)
* feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it Customer report: neither the MCP transaction listings nor v1 REST said which bank account a transaction belongs to, so per-account reconciliation could not be driven from outside and a difference on one account was hunted on another. - gnubok_list_uncategorized_transactions: cash_account_id + cash_account_ledger (BAS account of the bank account, one lookup per page) on every row, and an optional cash_account_id filter applied to both count and page. - transactions_without_documents RPC (new migration, same signature): rows carry cash_account_id + cash_account_ledger via LEFT JOIN cash_accounts; gnubok_list_transactions_without_documents declares them. - v1 transactions list/detail: cash_account_id column; list accepts ?cash_account_id=<uuid> (400 on non-UUID). - tools/list budget bumped 59.85K -> 59.9K with the usual log entry; no property descriptions added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): import insertCashAccount in the pg test, regenerate banking.md, validate cash_account_id Skeptic/CI findings: the new pg-real test referenced insertCashAccount without importing it; the accounted-api agent skill (banking.md) was stale after cash_account_id joined the v1 projections (apiskill:check). Also reject a ledger number passed as cash_account_id on the MCP tool with a clear message instead of a raw uuid cast error, since the ledger now sits next to the id in every row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9622382579 |
fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver (#1807)
* fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver gnubok_link_transaction_to_journal_entry failed reproducibly for a customer on certain incoming payments with a bare LINK_TX_DB_ERROR: the service put the Postgres message in details.reason, but the code had no structured entry and the commit dispatcher dropped executor data on failure, so neither the MCP approve result nor result_data said why. LINK_TX_DB_ERROR now has a structured entry; the executor appends the DB reason to the message and sets errorCode; the dispatcher persists and returns executor failure details (result_data.details, CommitResult.data, .code); gnubok_approve_pending_operation exposes error_code. The next failing call tells us which constraint or trigger fired. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): keep tools/list under the context budget (drop approve schema descriptions) The two output-schema descriptions added for data/error_code pushed the projected tools/list payload 7 tokens over the ceiling guarded by payload-size.bench.test.ts. The fields stay; the prose goes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pending-ops): log loudly when the terminal rejected write fails Review finding: the rejection branch wrote pending_operations without checking the result, so a failed write left the row in 'committing' with the executor error, code and details lost silently. Mirror the finalize branch: inspect the write result and log with the ids plus the failure we could not persist; the daily recovery sweep still resolves the row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bf3104dd21 |
fix(mcp): query_journal text search runs two-step instead of the LATERAL embed (#1804)
The two free-text ilike legs of gnubok_query_journal were the last users of the journal_entries!inner embed. PostgREST compiles that embed to a correlated LATERAL join that walks every tenant's journal_entry_lines, and prod logs showed it as the query behind the daily 8 s statement timeouts (SQLSTATE 57014), which reached agents as a generic UNKNOWN_ERROR. - Both legs now run fetchEntryLines (lib/bookkeeping/entry-lines.ts) over the same entry/line filter set as the plain query: leg A ilikes journal_entries.description on the entry side, leg B ilikes line_description on the line side. Leg B fetches the entry ids the plain query already fetches, but only the matching lines, so it is never more expensive than the same query without text. - Each leg pulls its full match set, so text queries now report exact totals/total_lines/truncated; legLimit/legCapHit and totals_scope='returned_slice' are gone. The totals_scope field stays, always 'full_match'. - The amount filter runs before the display slice on every path, so limit=N returns N matching lines instead of N minus what the filter removed. - DB failures are still sanitised, but a transient one (statement timeout, connection drop) now carries code TRANSIENT_ERROR plus a hint to retry or narrow with date_from/date_to, so the structured-error layer returns the retryable envelope instead of "Något gick fel". Tests: text suite rewritten on a filter-aware two-step fake (no .from() call-order pinning), plus text+date-range, no-embed, amount-before- slice, and 57014 -> TRANSIENT_ERROR cases. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e53b478833 |
fix(mcp): query_journal accepts scalar/number accounts instead of silently dropping the filter (#1803)
* fix(mcp): query_journal accepts scalar/number accounts instead of silently dropping the filter
Hosts don't always enforce inputSchema. `accounts: 1630` (JSON number) has no
`.length`, so the account filter was skipped while applied_filters still
echoed it, returning every account's lines as if filtered. A bare string
"1630" was spread into its digits by postgrest-js `.in()` and matched nothing.
Normalize accounts (array / bare string / comma list / integer) to string[]
and account_from/account_to to strings; reject values that aren't account
numbers with a clear error instead of ignoring them. Tests assert the actual
`.in('account_number', [...])` predicate, not just the echo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): reuse shared ACCOUNT_NUMBER_RE, drop null entries, normalize tag_journal_lines filters
Review fixes: the local ACCOUNT_NUMBER_RE collided with the import from
lib/invariants/account-number (build break); null/undefined array entries
now drop out instead of reaching .in(); gnubok_tag_journal_lines (a bulk
write path) gets the same accounts/account_from/account_to normalization so
a mangled filter fails fast instead of widening the retag scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0040cadacc |
feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain Companies holding the custom_sender_domain capability grant can register their own domain (Resend sending-only profile), publish DKIM/SPF, and once verified every invoice email (send, reminders, recurring, payment confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>" instead of the platform sender. Reply-To is unchanged. - New table company_sending_domains (RLS: members read, owner/admin write; audit trigger), types, archive-export classification. - New capability key custom_sender_domain: manually granted per company, deliberately outside PAID_CAPABILITIES (never trial-seeded, never written by the Stripe sync). Without the grant the settings section is hidden and nothing changes. - Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify), Resend domain lifecycle without orphan adoption, domain.updated handling on the delivery webhook, explicit From support in the Resend adapter. - Core resolveInvoiceSender(): verified + enabled + entitled, else the platform sender; never throws. - Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en). - Unit tests for the resolver, domain helpers, routes, From header; pg-real test for RLS and constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): harden sending-domain writes, sender fallback, review findings Skeptic refutations: - Tenant JWTs could insert/update company_sending_domains with status = 'verified' and an arbitrary domain through PostgREST (RLS only checked membership), then send invoice mail as that domain. New migration 20260822130000 adds a BEFORE trigger: tenants may only open a pending claim and edit sender_local_part/sender_name/enabled; domain and verification state are service-role only. claim/verify helpers now take a service-role writer for those columns; the route's RLS client still does the insert. - A company domain Resend later rejects made every invoice send fail: the Resend adapter retries once as the platform sender when an explicit company From is rejected (nothing was sent, so no double send). Review findings: - domain.updated webhook: discriminated outcome; DB errors answer 500 so Svix retries, unknown domains are acknowledged. - Display names are RFC 5322-quoted only when they carry specials. - Sender local part is a strict dot-atom (no trailing/consecutive dots), in code and in the CHECK constraint; resend_domain_id index is UNIQUE. - IME composition guard on the claim input; event bus reset in tests; settings section skips its request for non-admins. Deferred (needs a product call): persisting the effective From address in the invoice delivery log touches the hardened evidence triggers; recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a tenant could delete and re-insert its pending row under the same id with a reserved domain, and the service-role writer updated by id alone. Now: - the claim's verification-state write filters on (id, company_id, domain, resend_domain_id IS NULL) and rolls back on zero rows; - verify and the domain.updated webhook compare Resend's domain name with the row before writing verified; - resolveInvoiceSender refuses reserved platform domains and non-hostnames at send time (reserved-domain logic moved to lib/email/domain-name.ts and shared with the claim validator). pg-real: the case-insensitive uniqueness assertion now expects the domain_shape CHECK (lowercase enforced) for an uppercase variant and the unique index for a same-case duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dc5079a912 |
fix(providers): stop inventing 25% VAT on migrated invoices (#1745)
* fix(providers): stop inventing 25% VAT on migrated invoices An invoice migrated from Fortnox displayed "Momsbehandling: 25 % moms" next to "Moms: 0 kr", with no line items behind it. It was not a display bug: the record really did hold vat_rate 25 and vat_amount 0. Fortnox answers GET /3/invoices with the short form, which carries no Net, no TotalVAT and no InvoiceRows; those live only on the detail form. The migration mapped the list payload alone, so `Net ?? total` made the net equal the gross, VAT derived as gross minus net came out 0, and with no rows to read a rate from, inferVatTreatment/inferVatRate fell through to their `return 'standard_25'` / `return 25` defaults. The result balanced, so nothing downstream noticed. Measured on prod: 8 712 sales invoices across 43 companies assert a rate beside 0 kr of VAT (286 MSEK of subtotal), plus 1 240 supplier invoices. None are booked, but 263 are still open, and the no-items booking fallback in invoice-entries.ts credits the full gross to 30xx and emits no 2611 line at all. Not Fortnox-only. Visma reported its VAT-inclusive TotalAmount as the ex-VAT amount and read rows via `LineTotal`/`VatRatePercent`, neither of which exists in the eAccounting schema (the real names are AmountNoVat and PercentVat), so its lines all landed at 0. Bjorn Lunden reported the gross as the net with no lines at all. Briox and WINT had the same gross-as-net fallback, and Bokio defaulted a missing totalTax to 0. - lib/providers/amounts.ts: readers that return undefined for an absent field, so "the provider says zero" stays distinct from "did not say" - every mapper: populate taxTotal and per-line taxAmount from what the payload actually states; leave the net undefined when it does not - provider-data-fetcher: hydrate the detail endpoint that every config has always declared and nothing ever called, open invoices first, within a time budget, reporting whatever it could not reach - entity-mapper: derive rate and treatment from evidence; when there is none, write vat_rate null and flag vatUnresolved instead of asserting a standard rate Existing rows are untouched; repairing them needs a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): keep subtotal + VAT equal to the invoice total Providers state net, VAT and gross independently and they need not agree: Fortnox's Total is the amount to pay after öresavrundning while Net + TotalVAT is the unrounded Gross, so the two differ by up to 50 öre. Passing both through as stated put that gap into the invoice row, where subtotal + vat_amount no longer equalled total. The header booking path in invoice-entries.ts derives the 1510 debit from the sum of its credits, so the receivable would land a few öre away from what the customer owes while the verifikat still balanced: the same silent shape as the bug this branch fixes. resolveVatTriple now always returns a pair summing to the gross, keeping the VAT intact (it reaches the momsdeklaration) and absorbing the rounding into the net. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): address invoice detail by the configured idField Hydration built the detail path from dto.id. Björn Lundén's sales config names invoiceNumber as its idField while its mapper builds dto.id from entityId, so BL sales invoices would have been hydrated from the wrong resource, or from none. Every other provider/resource pair happens to agree on the two, which is what made the mismatch easy to miss. The config's idField is the authority, read off the raw payload, with dto.id only as the fallback. The regression test uses BL with entityId 99001 and invoiceNumber 5 so the two cannot coincide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): store vat_rate null for migrated mixed-rate invoices resolveInvoiceVat labelled the header with the first line's rate, so an invoice carrying both 25 % and 6 % lines was recorded as a 25 % invoice. buildInvoiceWriteData already stores isMixedRate ? null : theRate for natively created invoices; migrated ones now match. The money was already right and stays right: generatePerRateLines groups per item rate, so a mixed invoice books 25 % and 6 % separately off the per-line vat_rate/vat_amount this branch fixed. Only the header label was overstating what the source said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): bound hydration against auth failures and the clock Two failure modes that only appear against a real provider. A 401 or 403 fails identically for every remaining invoice, so the pass now stops on the first one instead of issuing hundreds more doomed calls. That matters more than it looks: TokenBucketRateLimiter keys on the literal string 'global', so Fortnox's 4 req/s is a platform-wide budget shared by every company and every concurrent migration, not a per-token one. A 404 is about one invoice and does not stop the pass. The budget was checked before starting a call but never during one. The clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s apart), so a call starting one millisecond inside the budget could still be retrying minutes later, and three concurrent ones could hold the migration past its 300 s function ceiling. Each call is now raced against the deadline; the socket is not cancelled, but control returns and the remaining invoices are reported unhydrated instead of the run dying. Both outcomes are reported as HydrationReport.abortedBy so a partial pass is visible rather than looking complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <invoice@arcim.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
13b69a2056 |
fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)
* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and the v1 REST API, but not the MCP path, and the web customer list still showed a personnummer raw when it sat in org_number. Personnummer (MCP + every write path): - gnubok_create_customer gets a personal_number input. Until now it had none, so an agent creating a private person either dropped the number or put it in org_number, which nothing masks. Encrypted at staging (personal_number_encrypted + personal_number_masked; personal_number is now a forbidden staging key in staging-pii-guard), the approval preview shows ********-1234, commitCreateCustomer stores the ciphertext as-is. Idempotency hashes the masked preview (new StageOptions.idempotencyParams) because the random-IV ciphertext would make identical retries look like payload changes. - A personnummer-shaped org_number on customer_type=individual is the personnummer in the wrong field: it is moved into personal_number (encrypted) and org_number cleared, on CreateCustomerSchema (web POST, v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer for in-flight ops. Only a DIFFERENT personnummer next to personal_number is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type guard from #1724 is unchanged and now also fires at MCP staging, so the user never approves an operation that fails at commit. - Read side: the web customer list and gnubok_list_customers mask a legacy individual row's org_number personnummer instead of showing it raw; list_customers exposes personal_number_masked and never the ciphertext. - scripts/repair-customer-personal-number-in-org-number.ts moves the existing rows (dry run: 134 rows across 10 companies on prod); run by hand with --confirm after deploy. - customer-onboarding skill: EF customers follow the #1724 decision (individual + personal_number); ROT/RUT section names the real field. Payment terms (MCP): - gnubok_create_customer staged `payment_terms || 30`, so resolveDefaultPaymentTerms at commit always saw 30 and the company's invoice_default_days never reached MCP customers. Resolved at staging now, so the preview shows the value the row will get. tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first, rationale in payload-size.bench.test.ts). apiskill regenerated; no migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk * fix(scripts): literal update payloads in the personnummer repair script The no-phantom-columns scanner counts a runtime-built update payload as unresolvable and the ceiling (379) had no headroom; two literal payloads keep the guard able to resolve both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
524d9978f1 |
fix(migration): resumable underlag import without inline extraction + same-origin MCP storage URLs (#1783)
* fix(migration): resumable underlag import without inline extraction, same-origin MCP storage URLs The Fortnox underlag import ran every file's AI extraction inline inside one request and hit the hosted 300 s function limit after ~17 of 113 files (twice on 2026-08-21); the UI showed the generic "underlagen kunde inte importeras" although the files it did reach were linked. The import now works in time-budgeted slices with a stable cursor (the UI loops until the server reports the end and shows "x av y") and opts out of extraction (extractionOwner 'none', stamped skipped:opted_out): every file is linked to its posted verifikat on arrival, so the booking is already known. MCP signed Storage URLs (upload_url, signed_url, download_url) are served through a same-origin proxy, /api/storage/[...path], because Claude Desktop's sandbox only reaches the MCP host and blocked the PUT to <project>.supabase.co. The signed token stays the only credential; the proxy forwards only signed documents-bucket paths to our own Storage host and is a no-op rewrite when NEXT_PUBLIC_APP_URL is unset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(mcp): keep the storage-proxy note out of the size-capped tool descriptions The per-tool 280-char cap and the tools/list payload ceiling both tripped on the two sentences added to gnubok_create_document_upload and gnubok_get_document_content; the why now lives in a code comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(review): id cursor, stall = error, capped upload body, encoded dot segments Review follow-ups on #1783: - the import cursor is the last handled provider attachment id, not an index, so a file Fortnox adds or removes mid-sweep shifts nothing - a partial answer whose cursor does not advance (or the round guard) is reported as ARCIM_DOCUMENT_IMPORT_STALLED instead of "complete"; the slices already landed stay reported and the retry button resumes - the storage proxy reads the PUT body as a capped stream instead of buffering an unbounded payload before measuring it - object paths are rejected when any segment decodes to "." or ".." (or holds a separator), and the URL fetch() would actually request is re-checked against the allowlist after normalisation - download_url description no longer claims a direct Storage URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
60920ec794 |
feat(skatteverket): expose filed VAT declarations and decisions via the v1 API (#1773)
* feat(skatteverket): expose filed VAT declarations and decisions via the v1 API Add GET /api/v1/companies/:companyId/skatteverket/vat-declarations, returning a period's momsdeklaration as Skatteverket has it on file: the submitted declaration (SKV /inlamnat) and Skatteverket's beslut (SKV /beslutat), either individually via ?state= or both. - Auth: compliance:read scope; member-visibility read model per #1673 (resolveReadAuth: caller's token, any member's active token, or system credentials with a verified ombud grant). - Architecture: core reaches the Skatteverket extension through the registry-resolved services channel (contract in lib/skatteverket/declaration-status.ts), so core never imports from @/extensions/. - New structured error SKATTEVERKET_API_ERROR (502) for upstream SKV failures; 404 from SKV maps to submitted/decided = null with HTTP 200. - 19 new tests (route: auth, validation, extension-disabled, happy path; extension service: auth resolution, state filtering, SKV error mapping). Fixes #1663 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): address review findings on the vat-declarations read API Consolidated fixes for PR #1773 review round: - apiskill sync (core-build Checks): map the new skatteverket endpoint group into the periods.md reference and regenerate skills/accounted-api (124 -> 125 operations). - CodeRabbit: parse the SKV 2xx body before writing the audit row, so an unreadable body is audited as skv_error and returns the structured SKATTEVERKET_API_ERROR 502 instead of escaping as an internal 500; regression test added. - Compliance swarm (ISO A.8.12 / SOC2 CC6.1): stop forwarding the raw upstream SKV response body to API consumers; the caller now gets the status code and a generic Swedish message, the body is logged server-side only. - Compliance swarm (GDPR Art.30): add the moms.declaration_status_read processing activity to .compliance/ropa.yaml (live read, no payload persisted, audit-log metadata only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f3e4fdcf32 |
fix(providers): stop the Fortnox reconnect loop, and make the attachment scopes opt-in (#1761)
The Fortnox document import needs the archive and connectfile scopes, which the registered Fortnox app does not have. Since #1549 pulled them out of the connect request (they broke every connect with invalid_scope before login), every attachment call fails and the user was told "Koppla om Fortnox och godkann behorigheterna", under a button that reruns an authorize URL still not asking for those scopes. Klura AB followed that loop four times and bought the Fortnox Arkiv module trying to satisfy it. Prod evidence: no Fortnox attachment has ever imported, across 166 companies and 24 consents since the feature shipped, and no live token carries the scopes. The error and the scope list now derive from one flag, FORTNOX_DOCUMENT_SCOPES_APPROVED. While it is false a permission failure maps to a new PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, which says the permission is missing on our side, that reconnecting will not help, and that the rest of the migration came through; the card offers no button, because no user action can succeed. The attachment scopes also become an opt-in consent rather than part of every connect. Fortnox derives customer licence requirements from what an integration requests, so asking everyone for Arkivplats would put a licence in front of customers who never import a receipt; and keeping it off the default connect caps the blast radius of a wrong portal registration at the underlag flow rather than every Fortnox connection. buildFortnoxAuthUrl already took per-call scopes, provider-client simply never passed any, so this threads documentScopes from that one button through /connect into the authorize URL. A document consent is always a superset of an ordinary one: the callback overwrites the consent's tokens in place, so a narrower grant would revoke the migration's own ledger access. Pinned by a test that holds either way the flag is set, alongside one for the 400-with-behorighet answer that six companies hit between 08-13 and 08-19 and saw only a generic retry for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
febb4cc0c2 |
fix(import): let provider re-sync re-import an earlier fiscal year after data deletion (#1763)
* fix(import): let provider re-sync re-import an earlier fiscal year after data deletion After partially deleting imported data, a provider re-sync could not bring back the previous fiscal year: the sie_imports 'completed' watermark survives data deletion, the replace path aborted the whole year when the prior import row could not be resolved, and prior-import detection picked an arbitrary row when several overlapped the same year. - findOverlappingPeriodImports returns ALL overlapping completed rows, newest first; checkDuplicatePeriodImport now picks deterministically. - executeSIEImport replace mode resolves every overlapping row. A row that is gone or no longer 'completed' (replaceSIEImport codes not_found / not_completed) is a stale watermark: skip it with a warning and import the year fresh instead of stranding the user. Locked/closed periods and RPC failures still abort the year. - The arcim-migration wizard names the fiscal year in every per-file import failure and shows the newest prior import in the options step. Fixes #1667 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): fail closed when the replace pre-check query errors replaceSIEImport's pre-check discarded the .single() error, so a transient query failure (statement timeout, network error, 5xx via PostgREST) was indistinguishable from a genuinely absent row and got classified not_found. The replace loop in executeSIEImport then treated it as a stale watermark and imported the fiscal year fresh while the prior completed import's verifikationer were still in the ledger, with duplicate checks skipped in replace mode: silent duplicate verifikationer for a whole year (BFL 4:1 risk). Only PGRST116 (zero rows from .single()) now classifies as not_found; any other pre-check error returns rpc_error, which aborts the year in the replace loop. Tests cover both classifications plus the executeSIEImport-level abort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): fail closed on overlap lookup, verify zero surviving entries before a stale-watermark skip Review findings on #1763 (CodeRabbit + Swedish compliance review): - findOverlappingPeriodImports now uses fetchAllRows: query errors throw instead of returning [] (which let replace mode import fresh over rows it never resolved), pagination passes the PostgREST row cap, id tiebreak keeps the order total. - A stale-watermark skip (not_found/not_completed) is only trusted after a positive check that zero posted import entries survive in the fiscal year: replace_sie_import deletes by fiscal period, so entries can outlive their sie_imports row. Survivors or a failed check abort the year. - Contract comment tying the stale-race regex to the RPC's RAISE wording. - Suite-level beforeEach clears mocks and the event bus (repo convention). Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c7a75d069d |
feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice (#1740)
* feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice Sovereign plan WS1 PR1 (#1406 Tier 2, extraction-first, aligned with the AI surface audit). lib/ai grows a job-shaped service (generateText / generateStructured / extractFromDocument; no streaming members yet, see plan rule R3): - services/anthropic-family delegates to the existing createAiClient() and sends the exact request literals the inbox extractor sent before (request-shape tests deep-equal them), so hosted Bedrock stays byte-identical. - services/openai-compatible talks to any chat-completions endpoint (BYO Swedish provider) via Vercel AI SDK 6.x, exact-pinned and guarded: images as parts, PDFs rasterized with poppler (AI_PDF_MODE) or sent natively, AI_VISION / AI_STRICT_JSON declared, honest skips (ai_no_vision, pdf_rasterizer_missing) instead of fake failures. - config.ts: AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL and per-tier AI_*_MODEL with the legacy BEDROCK_* names kept as the same overrides; getAiStatus() is the single source of truth for "is AI wired up". - provider.ts: openai-compatible in the auto-detect chain (after Bedrock and the direct API); createAiClient() refuses it loudly. Document extraction moves onto the service and gets the audit's fixes: - Inbox documents were extracted TWICE (pipeline A ran inside uploadDocument() before the inbox row existed, so its dedupe branch never fired; 3 707 + 1 666 calls / 30 d). The inbox now declares extractionOwner on the upload, the extension yields, and the inbox mirrors its single outcome onto document_attachments from every writer (sync, deferred, attach, retry, MCP). - Every "no extraction will ever happen" outcome is stamped (skipped:no_ai_entitlement / ai_unconfigured / system_generated / ...); the status route maps the quiet ones to 'disabled' on the first poll instead of a 30 s client timeout. Prod showed 309 of the 327 never-extracted uploads were the paywall working silently. - Self-generated documents (our own invoice PDFs, payout files) are no longer OCR'd. - Agent invoke answers 503 ai_unconfigured when the deployment has no assistant backend, distinct from the paywall. Guard: new direct-ai-client antipattern check (shrink-only allowlist of the pre-abstraction SDK callers) plus exact pins for @anthropic-ai/sdk, ai and @ai-sdk/openai-compatible. Verified: 15 958 unit tests green, guards, lint ratchet, typecheck, and a live smoke against hosted Bedrock through the new service (ping, streamed tool turn, thinking+cache, PDF extraction). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): make AI_API_KEY optional for OpenAI-compatible endpoints (keyless local model servers) A local model server (llama.cpp's server, Ollama /v1, LM Studio, vLLM) usually has no auth. Before, the OpenAI-compatible backend required both AI_BASE_URL and AI_API_KEY to count as configured, so running Accounted on a local model meant setting a meaningless placeholder key. - resolveAiProvider / hasAiCredentials: a base URL alone is now enough. - services/openai-compatible: only send Authorization: Bearer when AI_API_KEY is set, so a keyless server is never handed an empty bearer; a hosted provider that needs a key still sets it. - Docs (SELF-HOSTING Option 3: local-model example, key marked optional), DECISIONS. Verified: with no AI_API_KEY, just AI_BASE_URL + AI_MODEL, getAiStatus() reports configured=true / provider=openai-compatible (live). lib/ai suite 71 green; tsc, guards, lint clean. Bedrock/Anthropic logic unchanged. 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> |
||
|
|
e733ab7c43 |
fix(arsredovisning): unblock the signing flow, accept foreign parent org nr, explain Fortnox underlag failures (#1738)
Batch from a real migration walkthrough (Fortnox -> Accounted, 2026-08-20): - Årsredovisning: the "Låst version" select was empty with no explanation because the only version was a draft and "Lås version för underskrift" is disabled while the four Lagstadgade upplysningar checkboxes and the content confirmation count as blockers. The select is now disabled with a hint that names the blocker count and links to Fullständighetskontroll, the four AR-NOTE-*-UNCONFIRMED issues carry remediation text, the lock button explains why it is grey, and "Markera som signerad" says what it still needs (locked version, bevisreferens, date). - Moderföretagets org.nr accepts a foreign registration identifier (CHE-123.456.789, HRB 12345, 923 609 016); personnummer shapes stay out. - Fortnox underlag discovery: log status, body and Fortnox's message on failure, show the message in the UI, treat a 400 with behörighet/scope text as scopes-required, and fall back to an unfiltered voucherfileconnections list when the financialyear filter answers 400. - Kontomapping: the Momskod column had min-w only; table-fixed collapsed it and its selects overflowed into Konfidens. Real w-72 now. - SIE import warnings pluralise correctly for one skipped voucher; the Verifikationsserie option says the source series is preserved. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b5e908f9ea |
feat(reconciliation): explain the difference instead of just printing it (#1737)
The bankavstämning card showed three movement sums and a red difference, leaving the user to work out what the difference consisted of. The page already knew, exactly: every krona of it is (unmatched bank rows) minus (unmatched vouchers). Verified on prod for Arcim 1930 over 2025-07-17.. 2026-08-20: 403 565,42 bank, 332 680,93 booked, 70 884,49 difference, of which -277 799,92 sits in 74 unmatched transactions and -348 684,41 in 4 unmatched vouchers, leaving exactly 0,00 unexplained. Engine: getReconciliationStatus gains unmatched_transaction_total, unmatched_gl_line_total and unexplained_difference. The residual, not the raw difference, is the figure that can mean something is wrong: a difference is expected to be large mid-year and says nothing on its own. unmatched_gl_line_total is null rather than 0 on a foreign account, whose candidate lines carry no amount in that currency, and the card falls back to the flat figures there. Also fixes the candidate fetch's window: it used the caller's raw dateFrom while both other sides were clamped to the opening-balance floor, so a window opening before the account's IB (the v1 endpoint's default, or any multi-year range) counted vouchers from a period the reconciliation deliberately drops. UI: the card becomes a bridge whose two middle rows both explain the number and navigate to the list that resolves them, above a matched/total progress rule. Three stacked paragraphs of legal prose collapse into one line plus a tooltip, keeping the amounts on screen. The permanent destructive "Ej avstämd" badge is gone: being mid-year and unreconciled is the normal state, so it marked nothing (convention 5); Avstämd is now what gets the chip. The unmatched list becomes one line per transaction (convention 4). It rendered a ~230px card per row, each with an always-open, always-empty match field: for a real backlog that is thousands of pixels of empty search boxes, and it gave the rarest action the only visible affordance while bokför and ignorera hid behind the row menu. The picker, and its ranked-candidate fetch, now run for the one row the user opens. A non-zero residual is stated factually, never in destructive red: measured over the 206 single-1930-account companies with >=10 transactions, 136 are exactly 0,00 and 63 are >=100 kr out, dominated by ledger lines the candidate RPC hides (posted/storno on 127 companies) rather than user error. Surfacing those is follow-up work. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4cf227001d |
fix(skattekonto): bound the sync to the first räkenskapsår, add an ignore path, EF-aware avdragen skatt (#1729)
* fix(skattekonto): scope the sync and the avdragen-skatt rule for enskild firma
Two EF problems on the skattekonto surface:
1. Stuck pre-company rows. The sync never passed datumFrom, so SKV's
~555-day default lookback imported the owner's PERSONAL skattekonto
history from before the company existed. Those rows can never be
booked (no fiscal period covers them), never deleted (external
mirror), and had no ignore path: visible forever.
- syncSkattekonto now bounds the fetch at the company's earliest
fiscal_periods.period_start (new getEarliestFiscalPeriodStart in
period-service; no bound when no period exists yet). Applied
uniformly to EF and AB.
- New skattekonto_transactions.is_ignored column (migration
20260819080000, copies the transactions.is_ignored precedent:
CHECK that an ignored row has no journal_entry_id, partial index;
the existing company-scoped UPDATE policy already covers it) plus
PATCH /skattekonto/transaktioner/:id/ignore (409 on booked rows,
race-guarded on journal_entry_id IS NULL). Ignored rows leave the
default GET buckets; ignored_count is always reported and
include_ignored=1 returns the rows, surfaced as a count line +
"Ignorerade" band on /skattekonto and an Ignorera affordance with
confirm + Ångra on both /skattekonto and the /transactions inbox.
- PERIOD_LOCKED for a date before the first fiscal period now says
the row predates the company's bookkeeping and can be ignored,
instead of "lås upp perioden" (a dead end for those rows).
2. "Avdragen skatt" auto-mapped to 2710 for every entity type. For an
EF without employees that line is almost always A-skatt an outside
employer withheld from the owner's private salary, not the firm's
payroll liability. New data-driven skattekonto_rules.requires_employer
column (migration 20260819080100, set on the avdragen-skatt seed and
its per-company clones); the matcher gates such rules for an
enskild_firma unless company_settings.employer_registered is true
(the existing AGI gate signal, fetched in the same settings query).
Gated rows take the NO_COUNTER_ACCOUNT path with a distinct hint;
AB and employer-registered EF keep 2710 unconditionally. Regression
guard pins EF preliminärskatt to 2013.
The nightly sync upsert excludes is_ignored so it can never silently
un-ignore a row. New pg tests for the CHECK + RLS need a test:pg run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skattekonto): clamp datumFrom to the SKV window, gate ignored rows, widen the employer signal
Review fixes on the EF-scoping PR:
- sync: clamp datumFrom to max(earliestPeriodStart, today - 555 days); a
bookkeeping start older than SKV's 555-day default is omitted entirely,
since sending it would widen the window past the default and anything
older than ~915 days fails the whole sync with felkod 2. The misleading
"no-op for AB" comment is corrected and boundary tests added.
- booking/match: an ignored row now throws a typed ROW_IGNORED error
(409) before any draft is created or link is written, in both
bokforSkattekontoTransaction and matchSkattekontoToEntry.
- page: the Nasta dragning / shortfall math re-includes ignored upcoming
charges (SKV draws them regardless of our ignore flag) while the
work-list buckets keep excluding them.
- employer gate: treat employer_registered ?? pays_salaries as the
signal (same fallback as lib/tax/deadline-config.ts), so an EF that
attested pays_salaries keeps 2710 for avdragen skatt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(skattekonto): assert the is_ignored RLS toggle inside the rolled-back transaction
withUserContext always rolls back (tests/pg/setup.ts), so the previous test
wrote inside it and read the pre-write value back on the pool connection:
it failed against a correct policy and would have passed against a missing
one only by accident. The assertions now live inside the same transaction,
pin rowCount=1 (an RLS-filtered UPDATE silently matches zero rows), and a
new test pins the negative: a non-member's UPDATE matches zero rows.
Falsification-verified against a real Postgres: dropping the UPDATE policy
makes both tests fail; with the policy they pass.
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>
|
||
|
|
3de5dee553 |
fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728)
* fix(enable-banking): supersede the old connection on bank reconnect and stop renewal duplicates
A renewal performed via the bank list ("Anslut ny bank") created a second
bank_connections row and left the old one parked in 'expired' forever: an
eternal "Åtgärd krävs" card, a red status chip, transactions stranded on the
dead row (so the picker's gap-fill probe read the renewal as a first
connect), and re-imported history for no-IBAN accounts whose provider uids
change on re-authorization.
- New migration: additive superseded_by uuid (FK, ON DELETE SET NULL) +
superseded_at + partial index on bank_connections. Status 'revoked' is
reused for superseded rows (no CHECK change); superseded_by disambiguates
a supersede from a user disconnect. File only: not applied anywhere yet.
- New lib/supersede.ts: after the OAuth callback finalizes, park same-bank
siblings matched by IBAN overlap (an ACTIVE sibling without overlap is
never touched; no-IBAN fallback only for dead siblings when neither side
has IBANs), revoke their EB session only when countLiveSiblings says
nobody shares it, re-point their transactions in id batches, demote
leftover cash_accounts claims (the mirror then promotes them by IBAN),
carry last_synced_at + initial_sync_* onto the survivor, and emit the new
bank_connection.superseded audit event.
- /connect fresh path: 409 { code: 'EXISTING_CONNECTION',
existing_connection_id } when a non-revoked same-bank row exists, unless
the body carries force_new: true (escape hatch for a second login at the
same bank). Runs after the zombie sweep; reconnect-in-place unaffected.
- Dedup scope stability: StoredAccount.dedup_scope pins the external_id
account scope at first ingest (normalized IBAN, else the uid of that
moment), is carried across in-place reconnects and supersedes by IBAN
match, and sync.ts uses dedup_scope ?? IBAN ?? uid (stamping legacy rows
lazily). The external_id FORMAT is untouched.
- AccountPickerDialog gap-fill probe also includes superseded connection
ids so the renewal default never races the transaction re-point.
- Sync toast (BankSyncNowButton) now also reports skipped duplicates
(sv+en strings) so a correctly deduped renewal does not look broken.
Tests: supersede unit tests, /connect 409 + force_new, callback supersede
wiring + dedup-scope carry, sync external_id stability across uid changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(enable-banking): scope the connect 409 to dead siblings and harden supersede ordering
- POST /connect only 409s when the same-bank sibling is expired/error/
pending_selection: an active row (a second legitimate login at the same
bank) never blocks a fresh connect; force_new bypass kept. The 409 text
now names the bank and points at Fornya samtycke.
- supersede parks the sibling row BEFORE revoking its EB session, and skips
the revoke entirely (logged) when the park update fails, so a failed park
can no longer leave a live-looking row with a dead session.
- callback keeps a survivor account's explicit dedup_scope instead of
letting a carried sibling scope clobber it; carried scopes only apply
when the survivor's scope was derived (IBAN/uid fallback).
- sync-now toast joins its two sentences with '. ' so the imported and
skipped-duplicates messages no longer run together.
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>
|
||
|
|
1ded1af8fe |
fix(enable-banking): one primary action per connection state on /settings/banking (#1727)
Restructure the banking settings page so every connection state has a
clear hierarchy:
- One "Dina bankkopplingar" group sorted by state precedence
(pending_selection, pending, error, expired, expiring soon, active),
replacing the three-way group split. State derivation, sorting and
worst-state selection live in a pure, unit-tested helper
(lib/connection-state.ts).
- Exactly one page-level .attn sentence for the worst state, or none;
the BankSyncStatusChip is removed from this page (it linked to
itself; it stays on /transactions and /import).
- Each row shows one primary action per state (Valj konton, Forsok
igen, Fornya samtycke, Synka nu); everything else moves into a "..."
menu, and details (accounts, IBAN, balances, initial historik) sit
behind a collapsed disclosure. Expired rows never show balances.
- Expiring-soon active rows get a "Fornya samtycke" primary that
reconnects without a psu-type override (the server reuses the stored
psu_type); the explicit account-type choice stays in the menu.
- "Anslut ny bank" collapses behind one outline "Anslut en bank till"
button whenever a non-revoked connection exists; the reuse-session
group only shows while the connect-new surface is visible.
- Fresh connects to an already-connected bank are intercepted with a
renew-instead dialog; "Anslut som ny" proceeds with force_new: true
for the upcoming server-side 409 guard.
- In-flight 'pending' rows render as a spinner row ("Vantar pa banken")
instead of being invisible.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
bb5fafe87b |
fix(orders): book webshop orders against 1686 and stop the missing-account dead end (#1697)
Booking an order from the Orders page could fail outright on a fresh company. seed_chart_of_accounts() seeds a deliberately small chart: 3001/3002/3003 and 2611/2621/2631 are in it, but 3004, 3740 and the clearing account are not. All three are reachable from an entirely ordinary order (a 0%-rate line, an ore residual, or simply no payment-method mapping yet), and the engine treats a missing or inactive account as AccountsNotInChartError, so the user's first click on Bokfor returned an error naming accounts they had no reason to know about, with no way forward but to hand-add them. The book route now ensures the closed set of accounts our own prefill can emit exists before drafting. Deliberately narrow: only accounts in WEBSHOP_PREFILL_ACCOUNTS are ever created, and only when a submitted line uses one, so an account the user typed still surfaces as a real error instead of quietly growing the chart. A deactivated row is reactivated rather than duplicated, and every failure is swallowed so the engine's typed error still wins over a chart tidy-up. The unmapped default also moves from 1680 to 1686. 1680 is the generic "Andra kortfristiga fordringar" parent; 1686 "Fordringar for kontokort och kuponger" is what BAS defines for a claim on a payment provider, which is what money sitting at Klarna or Stripe actually is. The Stripe extension already settles against 1686, so a store running both surfaces now shares one clearing account instead of splitting the same receivable across two. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3a1b842e4a |
feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates |
||
|
|
3ec76d39db |
fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670 |
||
|
|
83932f2e07 |
fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it (#1692)
* fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it
When the kvittens cron (or the post-connect refresh) picks up a signed AGI it
deletes the period-scoped agi_submission_{period} cache on purpose, and the
salary run then rendered "Skickad till Skatteverket <date>" with no
kvittensnummer, signatory or signing time even though all three were stored
on agi_declarations. Since the cron runs every 15 minutes while the panel
polls only three times after the signing link is created, that was the
normal outcome for anyone who signs at an unhurried pace (#1597).
GET /agi/status now serves the receipt from agi_declarations
(kvittensnummer, response_data.signeradAv/signeradTid, submitted_at,
submittedAtEstimated) whenever the cache is absent; the cache still wins
when present because it is the only place the in-flight states live. The
declaration-sourced record deliberately carries no salaryRunId (the period
row is repointed at a correction run on regeneration), so ownership is
resolved from signeradTid/submittedAt against the run's agi_submitted_at
stamp and from updatedAt = submitted_at. AGIPanel labels the timestamp as
approximate when it is our reconciliation-time fallback rather than
Skatteverket's signeradTid. The MCP gnubok_agi_status tool uses the same
read.
Closes #1597
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: retry stalled Vercel preview build
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>
|
||
|
|
7d56e0ec01 |
fix(mcp): use canonical VAT deadlines (#1679)
* fix(mcp): use canonical VAT deadlines * fix(mcp): handle incomplete VAT settings * fix(mcp): block unknown VAT deadlines * fix(mcp): tighten VAT tool description * fix(mcp): fail closed on missing entity type * ci: retry timed-out preview * fix(vat): scope annual filing method requirement |
||
|
|
1af5846adf |
fix(skatteverket): let company members read data connected by another member (#1691)
Closes #1673. Token rows are per (user, company), but the read resolver short-circuited to the caller's own row whenever a userId was passed, so a member who never pressed "Anslut" resolved to "no token" for a company that was connected. The company-wide fallback used .maybeSingle(), which errors as soon as two members have both connected and turned that into "nobody connected" for everyone. - resolve-auth: findCompanyTokenUser() reads all of the company's rows ordered by created_at desc and picks the caller's own active row first, then any other member's active row, then needs_reconsent rows; the userId branch no longer short-circuits. The auth carries the token OWNER's userId so refresh writes back to the owner's row. - /skattekonto/saldo and /skattekonto/sync resolve the company token instead of getTokens(caller); /declaration/submitted and /decided answer a SESSION_EXPIRED reconnect prompt for needs_reconsent instead of NOT_CONNECTED. Connect/disconnect//status stay on the caller's own row. - The connection.expired event names the token owner, not the caller who triggered the sync; the notification lookup filters by company too. - The two kvittens crons flag needs_reconsent through the same shared pick instead of .maybeSingle(). Tests: two members, one connects, both read; both connect, both read; no row -> NOT_CONNECTED; dead-only rows -> reconnect prompt; sync auth carries the owner; event recipient is the owner. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c84f8b04c2 |
fix(mcp): return a clear validation error when categorize_transaction lacks category (#1688)
A call to gnubok_categorize_transaction carrying only account_override reached the enum check in categorizeTransactionCore and surfaced as 'Invalid category "undefined"'. Hosts do not always enforce inputSchema `required`, so the executor now guards presence at the boundary and throws "category is required; account_override only overrides the category's default account" (listing the valid categories). Unknown category strings still get the existing enum error. Same class in gnubok_bulk_book_inbox_items: a missing or unknown category was staged as-is and only rejected at approval time by the commit executor's Zod schema. It now fails at staging with the same clear messages. The account_override property description spells out that category stays required (it decides direction and VAT) and the override only replaces its default account. Closes #1662 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bc357531cc |
feat(shopify): port the order sync from the transactions feed to webshop_orders (#1676)
* feat(shopify): port the order sync from the transactions feed to webshop_orders Shopify orders now land as rich rows on the Orders page (platform 'shopify'), the same surface WooCommerce uses, instead of opaque bank-feed rows on the 1584 cash account: - order-sync.ts writes through the shared upsertWebshopOrders service; the 1584/ensureManualCashAccount wiring is gone (prod has zero Shopify feed rows). Cursor/overlap/dedup, revoked classification and the frozen external_id formats are unchanged. - vat_breakdown is reconstructed from the order-level taxLines (net = tax/rate, remainder as a 0%-bucket, refuse on unusable data); refund VAT is prorated from the parent order's mix. The line-item snapshot is stored only when it reconstructs the charged total to the ore, else the invoice conversion falls back to one aggregate line. - GraphQL query gains createdAt, taxesIncluded, taxLines, lineItems and shippingLines (all non-PII; page size 100 -> 25 for query cost). - Nav gate counts active shopify_connections; the Orders empty-state CTA goes to the platform-neutral /import hub; panel/manifest copy now points at the Orders page (sv + en). - Paid-only qualification and the 90-day backfill stay; the bookkeeping-lock row filter is dropped (lock is enforced at booking, parity with WooCommerce). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(shopify): carry the prorated parent tax on refunds when per-rate bucketing is refused A refund whose parent vat_breakdown was refused (unreported rates) stored total_tax 0 and prefilled a 0%-refund with no moms reversal. The parent's total tax is now prorated into the refund row, so the booking dialog's ratio-inference fallback presents an editable bucket with the reversal instead (CodeRabbit + Swedish review + skeptic finding). Adds the mixed-rate line and truncated shipping-page tests CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cfdddb2d7e |
feat(mcp): customer_number on create_customer + Beta tags on webshop surfaces (#1677)
* feat(mcp): accept customer_number on gnubok_create_customer Parity with gnubok_update_customer: a customer number no longer needs a create-then-update two-step with two approvals. The staged params carry the trimmed number, commitCreateCustomer inserts it, and the payload-size ceiling is bumped 59.7K to 59.75K with a documented entry (the property has no description; name + maxLength are the whole contract). Requested by a user on Discord 2026-08-16. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): mark webshop integrations and orders tab as Beta WooCommerce and Shopify rows on the import page get a quiet Beta chip next to the title, and the webshop /orders sidebar item sets the existing betaBadge flag. Chip recipe matches the nav beta badge so Beta reads identically everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): enforce customer_number invariants and show it on the approval card Consolidated resolution pass for PR #1677: - skeptic (correctness): maxLength 32 was advertisement-only on the create path; now enforced with a runtime guard in gnubok_create_customer execute (clean errors for non-string and >32) and a 400 guard in commitCreateCustomer, matching the web/v1 routes and commitUpdateCustomer. - skeptic (correctness): CustomerPreview never rendered the staged customer_number, leaving the approver blind to the new field; added a conditional Kundnr row. - CodeRabbit: reset the event bus in create-customer.test.ts beforeEach. - Tests cover both new guards at the tool and executor layers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
76b8d5c100 |
fix(pending): show the staged kontering and bank currency on the bulk_book_transactions approval card (#1648)
The /pending card (and the chat ApprovalCard, same OperationPreview
dispatch) for bulk_book_transactions rendered only aggregates: tx_count,
tx_date, tx_sum, direction, mode. The staged journal lines sat unused in
params.new_entry.lines even though the executor's RPC posts them
verbatim, so the human approving an AI-staged samlingsverifikat could
not see which accounts were debited or credited: "-720, 2 tx, expense"
is compatible with both a correct booking and a wrong one.
- Staging now writes preview_data.lines (account_number, chart or BAS
account_name, debit/credit, line text) and entry_description, using
the same account-name lookup as gnubok_create_voucher, plus the bank
rows' currency. Nothing beyond what create_voucher already exposes;
still no per-tx descriptions or counterparty identifiers.
- New BulkBookPreview renders those lines with the create_voucher table
and totals, and shows the bank sum in the rows' own currency.
- CategorizePreview labels the source bank amount with its currency when
it is not SEK, next to the (always SEK) journal lines: a 2 500 USD
receipt booked as 24 292,50 kr read as a wrong SEK figure to an
approver who saw only one of the two numbers.
Reported via gnubok_feedback 2026-07-13 and 2026-07-14 ("the human-in-
the-loop control is the safety mechanism, and it is currently blind").
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
1470596591 |
feat(mcp): make agent feedback a read loop, advertise the tool, and stop over-promising (#1650)
* feat(mcp): make agent feedback a read loop, advertise the tool, and stop over-promising gnubok_feedback had collected 40 reports since May with no read surface (no page, digest, or script), while replying "We aggregate signal weekly". Triaged in full on 2026-08-17 (16 fixed / 12 open / 8 gaps / 4 partial; P0/P1 fixes in #1644-#1649). - New local loop skill /loop-feedback-triage: reads agent.feedback rows past a sequence watermark (seeded at 213147), verifies each against main, appends a dated digest to dev_docs/mcp_feedback_digest.md (local-only, dev_docs is gitignored), opens small fix PRs through the loop-verify gate. Never merges, never files issues. Closes the feedback-digest backlog item blocked since 2026-07-09 on a channel decision. - The tool is now advertised in the server instructions block and as feedback_channel in gnubok_get_agent_briefing (it was discoverable only by scanning tools/list). Reply copy is honest about what happens. - SIE duplicate-block errors name the blocking import id and point at undo-then-retry (gnubok_undo_sie_import / Angra import): agents were stuck behind a completed zero-entry import without knowing the way out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): trim feedback_channel schema description to stay under the tools/list size guard The added briefing field crossed the 59.7K projected-token ceiling by 6 once #1411's tool landed on main. Trimmed the description prose rather than bumping the ceiling, per the guard's own instruction. 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> |
||
|
|
79240cb2ed |
fix(articles): article ROT/RUT prefill was dead for every dashboard-created article (#1651)
* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article Follow-up to #1634. The user re-tested and picking a RUT article still left the line on "Ingen": the article form has always stored the bare kind ('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes (BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the bare kind, so the fix in #1634 never fired for a real user, and worse, since the helper returned null for those values, picking such an article CLEARED a deduction the user had set manually on the row. - rot-rut-rules: parseArticleHouseworkType() understands both vocabularies (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel(). - InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and keep a same-kind arbetstyp already chosen on the row; "Spara som artikel" round-trips the code or, lacking one, the kind. - ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper in ROT/RUT groups (its own hint always promised "förifyller arbetstyp"); legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an edit never silently drops the flag. Article detail renders "RUT · Städning" instead of the raw code. - API + MCP commit schemas normalize housework_type (case-insensitive code or ROT/RUT, '' clears) and reject anything else; the CSV article import normalizes the column the same way. Prod holds 178 articles with '0'/'1' from a boolean "Rot" column that the keyword detector mapped straight through; those now read as no flag everywhere and can no longer be created. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(articles): review follow-ups on #1651 - InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an arbetstyp from the other list, and Spara som artikel only round-trips a work type that belongs to the row's kind (CodeRabbit). - MCP update_article: null / '' / whitespace now clear housework_type (commit drops only undefined keys, so the old undefined mapping made the flag un-clearable); create keeps treating them as unset. Tests. - Article CSV import warns when a non-empty ROT/RUT value is dropped as not-an-arbetstyp instead of dropping it silently. Test. - Hint wording: arbetstyp is pre-filled only when the article carries one. 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> |
||
|
|
1cd33d9d97 |
PR 2 of 2: fix/inbox-retry-extraction-rematch (#1631)
* fix(inbox): match suppliers on VAT number, not just org-nr and name An extracted document auto-links to a supplier by org_number, then by an exact (case-insensitive) name match. The extractor deliberately leaves orgNumber null unless the document carries a real Swedish organisationsnummer, so for every foreign supplier the name was the only key left: "ADOBE SYSTEMS SOFTWARE IRELAND LTD" prints nothing but a momsregistreringsnummer (IE6364992H), which the suppliers table already stores in vat_number and which no code path looked at. Adds vat_number as a match key between org_number and name, and collapses the five inlined copies of the lookup into lib/suppliers/match-supplier.ts: - invoice-inbox upload (sync and deferred worker) - invoice-inbox PUT /items/:id/extracted-data - MCP createDocumentInboxItem (org-nr only until now: gains VAT and name) - MCP gnubok_set_inbox_extracted_data - MCP gnubok_create_supplier_invoice_from_inbox, which additionally read supplierExt.organizationNumber, a key the extraction schema never writes, so its org-nr lookup could not fire at all VAT numbers are compared on a canonical key (uppercased alphanumerics), so formatting variants match and a prefix-less "556012579001" still matches "SE556012579001"; two different country prefixes never do. Also escapes LIKE metacharacters in the name lookup, so a supplier named "100 % Solutions" is no longer a wildcard pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inbox): re-run the supplier match when the extraction is re-run "Tolka om" (POST /items/:id/retry-extraction) rewrote extracted_data and left matched_supplier_id untouched. It is the one affordance a user reaches for precisely when auto-linking failed, and it could not produce a link no matter how many times they pressed it: the match ran once, at first extraction, and never again. An item that arrived before its supplier existed stayed unlinked forever. The retry now runs the same shared matcher the other extraction paths use. Only a positive match is written, unlike those paths which also write null: a supplier the user picked by hand must survive a retry that finds nothing, which is the likelier case on a document that already failed to match once. Stacked on fix/supplier-match-vat-number for matchSupplierId(); merge that one first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inbox): write the retry-extraction update as two literal payloads The conditional spread for matched_supplier_id was one more dynamic payload than the phantom-column guard's ceiling allows (380 > 379), and a spread is exactly the shape that guard cannot check. Two inline literal update calls keep every written column checkable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> |
||
|
|
3841ab9f54 |
feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)
gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat links as a single pending operation, addressed by voucher_series / voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk receipt-migration jobs where N separate tools mean N separate approvals. Staging resolves every row server-side and returns a per-row hit or miss, so a systematic offset such as a wrong fiscal_year is visible before anything is approved rather than after N approvals. Only resolved rows enter the staged operation. The WORM precondition and the document lookup are shared with the single-document executor through precheckDocumentLink: a bulk call must enforce exactly the invariants N single calls would, and a second copy of a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the first is hardened. A batch that links nothing returns 409 instead of a committed no-op. Partial skips stay committed, but an approval-gated operation on räkenskapsinformation must not leave an audit record asserting a run that changed nothing. The tool is search-only: a one-off migration tool does not belong in the default catalog every session pays for in context, and keeping it there pushed the tools/list projection past the 58.5K token ceiling that payload-size.bench.test.ts guards. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4300cb711b |
fix(inbox): match suppliers on VAT number, not just org-nr and name (#1630)
An extracted document auto-links to a supplier by org_number, then by an exact (case-insensitive) name match. The extractor deliberately leaves orgNumber null unless the document carries a real Swedish organisationsnummer, so for every foreign supplier the name was the only key left: "ADOBE SYSTEMS SOFTWARE IRELAND LTD" prints nothing but a momsregistreringsnummer (IE6364992H), which the suppliers table already stores in vat_number and which no code path looked at. Adds vat_number as a match key between org_number and name, and collapses the five inlined copies of the lookup into lib/suppliers/match-supplier.ts: - invoice-inbox upload (sync and deferred worker) - invoice-inbox PUT /items/:id/extracted-data - MCP createDocumentInboxItem (org-nr only until now: gains VAT and name) - MCP gnubok_set_inbox_extracted_data - MCP gnubok_create_supplier_invoice_from_inbox, which additionally read supplierExt.organizationNumber, a key the extraction schema never writes, so its org-nr lookup could not fire at all VAT numbers are compared on a canonical key (uppercased alphanumerics), so formatting variants match and a prefix-less "556012579001" still matches "SE556012579001"; two different country prefixes never do. Also escapes LIKE metacharacters in the name lookup, so a supplier named "100 % Solutions" is no longer a wildcard pattern. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> |
||
|
|
43a71aec3c |
fix(supabase): stop server clients leaking a 30s refresh ticker per request (#1612)
* fix(supabase): stop server clients leaking a 30s refresh ticker per request
`autoRefreshToken` defaults to true in supabase-js, and off-browser
@supabase/auth-js starts the refresh ticker unconditionally:
// in non-browser environments the refresh token ticker runs always
this.startAutoRefresh()
That is a setInterval firing every 30 s. It calls unref(), so the process
still exits, tests pass, and Vercel never notices because the process is
torn down long before the tickers accumulate. But unref() does not make a
timer collectable: it stays registered in the event loop and remains a GC
root for its callback, which closes over the GoTrueClient, the
SupabaseClient, and the whole request scope around it.
A long-running self-hosted instance therefore leaks one timer plus one
entire request graph (socket, IncomingMessage, ServerResponse, headers,
route context: ~100 kB) per client constructed. One died of "JavaScript
heap out of memory" after 42 h, the last 24 of them completely idle. The
heap snapshot showed 445 retained request graphs and ~1050 Timeouts in
the 30 000 ms bucket, retained via `autoRefreshTicker`, and the rate
matched the traffic exactly: the Docker healthcheck polls /api/health
every 30 s and the webhook dispatch cron runs every minute, so
3 clients/min x 148 min = 444.
- new lib/supabase/service-client.ts: createServiceRoleClient() applies
SERVER_AUTH_OPTIONS, spread LAST so a caller passing its own auth block
cannot re-enable the ticker
- 22 call sites migrated; only booking-templates/sync/cron had ever
passed the options itself
- guard 9 in no-new-antipatterns.mjs fails CI on any new value import of
supabase-js's createClient outside the wrapper; type-only imports are
fine. Verified to fail on a deliberate regression and pass once fixed
- browser clients untouched: a signed-in tab genuinely needs the refresh,
and lib/supabase/client.ts is built on createBrowserClient anyway
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(checks): catch namespace imports in the leaky-supabase-client guard
The guard only matched named imports, so
import * as sb from '@supabase/supabase-js'
sb.createClient(url, key)
reached createClient through member access without ever naming it, and
passed. Verified against the real script before and after: the shape is
flagged now, and `import type * as sb` still passes.
Namespace value imports are treated as leaky outright rather than tracking
member access, which keeps the check a regex over source text with no new
dependency.
Review also suggested excluding *.test.tsx alongside *.test.ts. Skipped: the
repo has no .test.tsx files, and all four sibling checks in this file use
`.test.ts`. Diverging in one of them would read as an accident; if such files
appear, all four should change together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4921d1da5e |
feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)
* feat(import): import skattekontoutdrag files into the skattekonto pipeline Users can now upload the kontohändelse export from Skatteverket's skattekonto e-service (current CSV layout, verified against a real 2026-08 export, plus legacy .skv files) instead of needing the paid API connection. Parsed rows land in skattekonto_transactions as booked file_import rows and inherit the existing 1630 rules engine, bulk booking, match-to-verifikat and both UIs unchanged. - Core parser lib/import/skattekonto-file/ with strict detection (orgnr header + saldo markers, or two distinct SKV vocabulary terms plus row shape), sum-integrity check (opening + rows must equal closing) and a wrong-company guard against company_settings. - computeDedupKey moves to core (lib/skatteverket/skattekonto-dedup); the extension re-imports it. File rows hash-key; content-signature partitioning skips rows already booked (either key form) and promotes matching upcoming rows in place. - syncSkattekonto gains a takeover step: an id-keyed API row adopts a matching hash-keyed imported row in place, so journal links survive connecting the API after a file import. Upcoming rows can no longer clobber a booked row on hash collision. - New skattekonto_file_imports table (company-scoped file-hash dedup) plus source/file_import_id provenance columns on skattekonto_transactions. - /import gains a Skattekontoutdrag wizard (upload/preview/result, deep link ?mode=skattekonto); the bank-file flow detects skattekonto files and redirects instead of importing them as bank rows. - /skattekonto renders imported rows for unconnected companies (attn line + import CTA) instead of discarding them behind the StartCard. - Free for everyone: the local-data booking/match routes were already ungated; only API sync/saldo stay capability-gated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skattekonto): align the EF F-skatt rule with the 2012 -> 2013 decision 20260810120000 established that 2012 is not standard BAS and moved the booking templates to 2013 (owner taxes in an enskild firma are an eget uttag), but the skattekonto_rules seed still booked EF preliminarskatt against 2012. The file importer makes this rule fire for every EF F-skatt row, so bring it onto 2013 too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): apply review findings on the skattekonto file import - Fix the takeover candidate comparator: the single-argument sort was an inconsistent relation and could adopt a stale upcoming row ahead of the booked file row in a 3+ candidate queue (regression test added), and page the candidate scan with fetchAllRows so a multi-year window is not silently capped at 1000 rows. - Fail parsing when a statement HAS saldo markers but not both readable balances: a file cut off before "Utgående saldo" previously skipped the sum check entirely. sum_valid stays null only for marker-less legacy files. - Count a promotion only when the UPDATE matched a row, so a concurrent sync cannot inflate promoted_count; log a failed finalize of the import record instead of discarding the error. - Migration (unshipped, edited in place): user_id is nullable with ON DELETE SET NULL so import records and their file-hash dedup survive user deletion, and the INSERT policy binds user_id to auth.uid() so a member cannot attribute an import to a colleague. pg tests cover both. - Make the upload drop zone keyboard-reachable (role, tabIndex, Enter/ Space) and give the six count-bearing strings ICU plural forms in both locales. Skipped with reasons on the PR: binding execute rows to file bytes and re-checking orgnr in execute (same client-trust model as the shipped bank-file execute; Zod + RLS scope writes to the caller's own company), a 404 test (the route has no not-found path), event-bus clearing in the route test (the route touches no events), and FK NOT VALID (new column referencing a brand-new empty table). 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> |
||
|
|
caa0c3b41d |
feat(mcp): accept article_id on gnubok_create_invoice lines (#1638)
* feat(mcp): accept article_id on gnubok_create_invoice lines Invoice lines staged via MCP can now reference a catalog article (artikelregister). Staging prefills description, unit, unit_price, vat_rate and revenue account from the article with explicit-wins semantics, mirroring the web line picker. Unknown, foreign-company and deactivated articles are refused at staging, as is a price prefill from an article priced in another currency. The approval executor gains a company-scope gate for staged article_id values: the FK on invoice_items.article_id proves existence, not tenancy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): adopt article VAT rate only from the customer's default set Web line picker parity (InvoiceEditor's applyArticle guard): an article's stored vat_rate is its domestic rate. The prefill previously staged it unconditionally, and the staging/commit gates check the wider permitted set (which includes 25/12/6 for taxed-where-performed supplies), so {article_id, quantity} to a validated EU business staged 25% Swedish VAT onto a reverse-charge invoice. The customer is now fetched before the prefill and the article rate is adopted only when it is in the customer's default rate set; foreign-business lines fall back to the 0% reverse-charge/export default unless the agent sets vat_rate explicitly. Prefill logic extracted into resolveInvoiceLineFromArticle (CodeRabbit). Found by the skeptic review pass (two independent refutations) and the Swedish accounting compliance bot, all converging on the same defect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): name the article VAT adoption set in vat-rules, not the MCP server The vat-rate-gate-parity guard pins that no invoice write path mentions getAvailableVatRates: gating on the picker default is the bug it exists to prevent. The article-rate adoption in gnubok_create_invoice needs the default set for a different purpose (prefill, not gating), so the semantics move into lib/invoices/vat-rules.ts as getArticleVatRateAdoptionSet(), with tests pinning that adoption is empty for single-rate foreign customers and always a subset of the permitted set. server.ts keeps gating on getPermittedVatRates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f8db38f989 |
fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
86f0b70fdd |
fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement * docs(api): refresh account endpoint skill * fix(mcp): preserve ruta 05 compatibility * test(vat): seed migration constraint fixtures * docs(vat): clarify treatment precedence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
edfdbe2d2a |
fix(auth): move the BankID flow into a signed, user-gated, single-use cookie (#1625)
* fix(auth): move the BankID flow into a signed, single-use, confirm-on-resume cookie
A user's BankID signup identified successfully four times and created no
account. His screenshots show four tabs, one on the finished "Verifierad med
BankID, ange e-post" step, and the tab he was looking at showing the idle
button. Prod agreed: no bankid_identities row, no auth.users row.
On iOS outside plain Safari the BankID return URL is handed to the OS, which
opens a NEW tab. The session lived in per-tab sessionStorage, so that tab
started empty and rendered the start button while the completed flow sat
stranded. Login hid it (self-finishing, cookie-backed session); signup waits
for a human to type an e-mail into the stranded tab, so it dies there.
The session id is no longer handed to the browser. It lives in a signed
__Host- HttpOnly cookie set at /start; /poll, /complete, /link and /cancel
read it. Cookies are shared by every tab of the origin, which is what the
handoff needed. The id had to leave the client because it is an
unauthenticated bearer credential: /poll was skipAuth and returned
user.personalNumber, and /complete with mode 'login' returns a tokenHash that
verifyOtp turns into a session, MFA skipped for bankid_linked accounts.
A completed identification must never be consumed by whoever merely opens the
page. A shared cookie plus a shared machine means the tab that finds a
completed flow cannot prove the person at it is the one who made it, and no
client-side token can prove otherwise: nothing survives an iOS same-tab reload
yet dies on reopen-closed-tab / session restore / tab duplication. So a resume
is never automatic. The mount probe routes any found live flow to a confirm
card ("Fortsätt bara om det var du") that reveals no name, and only that click
polls and consumes. Auto-consume happens only inside the live component
instance that called startSession (desktop QR; the pre-navigation mobile
launch), which by construction is the originator. Cost: one tap after
returning from the BankID app on iOS, exactly where the reported bug lives;
desktop and Android never hit the resume path.
The rest is defence the four review rounds proved load-bearing:
- __Host- with Path=/ and unconditional Secure, so a script cannot plant the
same name at a longer path; readBankIdFlow fails closed on duplicates and on
a malformed percent-escape.
- Single-use is a unique index (bankid_consumed_sessions), claimed before
generateLink, not a Set-Cookie. Fail-closed on any non-23505 error, so the
migration MUST be applied before the code.
- A link flow requires auth at /start and pins userId; /link rejects a flow
owned by anyone else, before any TIC call. mode is pinned and /poll rejects a
body mode that does not match, so a login session cannot finish through the
signup panel. /poll withholds the holder name from a probe. The 900s
verified-step window is capped by MAX_TOTAL_LIFE from a signed startedAt.
/poll never clears the cookie (an untargeted Set-Cookie would delete a newer
flow); only /cancel and terminal /complete + /link exits clear. Avbryt holds
a 'cancelling' state until /cancel resolves so a new /start cannot race the
clear. Session id is logged only as an 8-char prefix.
The launch is untouched: iOS keeps its return URL, Android keeps redirect=null
(#194 closed that path deliberately).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(auth): bind BankID actions to the resumed flow
* docs: record BankID staging migration drift
* fix(auth): address BankID PR review
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2deea05d42 |
feat(import): attach underlag to SIE-migrated verifikat by filename (#1627)
* refactor(documents): lift the SIE voucher-ref resolver into core
The provider migration sweep resolved a source voucher reference to the
verifikat it became with an in-memory (period, series, number) index built
inside extensions/general/arcim-migration. The underlag filename import needs
the identical resolution, and core must never import from @/extensions, so the
index, its ambiguity handling and the two paged reads move to
lib/documents/voucher-ref-resolver.ts.
Behaviour-preserving for the extension: same index construction, same "drop
both when one key repeats inside a fiscal year" rule, same dateTo-window
resolution. The arcim tests pass unchanged.
Two deliberate additions on top of the lift:
- series comparison is now case-insensitive on both sides. SIE writes series
uppercase in practice but the spec does not require it, and a filename is
whatever the exporting tool produced.
- byNumber and fetchVouchersForNumbers serve the filename flow, which
resolves a handful of refs per request and must not pull every migrated
entry into memory to do it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(import): attach underlag to SIE-migrated verifikat by filename
A SIE file carries the ledger but not the underlag, so a migrating customer
brings the receipts over separately and today has to open every verifikat and
attach them by hand. Systems that export both name each receipt after its
verifikat (A31_<internal-id>.pdf), and the SIE import already preserves that
identity on every entry (source_voucher_series / source_voucher_number), so
the pairing is a lookup, not an interpretation: no AI, no amount matching, no
date windows.
Separate optional import mode (/import?mode=underlag), NOT a step inside the
SIE wizard: the receipts normally arrive later and from a different export, so
a migration must never be blocked on having them ready.
lib/documents/filename-voucher-ref.ts reads the ref out of a filename
lib/documents/underlag-import.ts builds the plan (reads only)
POST /api/import/documents/preview filenames in, match plan out
POST /api/import/documents/attach one file, archived and linked
components/import/UnderlagImportWizard review, adjust, run
Guards, because a document linked to a posted verifikat is
räkenskapsinformation and can never be re-pointed (BFL 7 kap):
- Matching keys on the SOURCE voucher number, never our own. The importer
renumbers per target series, so a file named after our number would land
on the wrong verifikat exactly when the import skipped a voucher.
- Nothing is uploaded until the whole plan has been shown: the preview
sends filenames only, the bytes stay in the browser.
- A ref that hits several migrated years is surfaced as a choice, never
resolved by guessing. So is a filename with a number but no series, which
is resolved but never pre-selected.
- A date-named file (20240131.pdf) is refused outright rather than read as
voucher 20240131.
- A target in a closed or locked period is shown but not selectable:
enforce_period_lock_documents would refuse the write anyway.
- The attach route re-resolves the filename server-side and 409s when it
does not name the target the client sent, so a stale plan cannot scatter
underlag permanently. An explicit manual assignment opts out of that check
and is flagged as such; company ownership of the entry is always verified.
- Idempotent per (verifikat, content): a re-run converges on the same
document row instead of archiving duplicates.
tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract
the plan surface promises, including that the lock guards the LINK and still
lets an unlinked document be archived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): scope underlag matching to a declared fiscal year
Adversarial review of #1627 refuted the resolver: it looked a ref up
company-wide and treated "exactly one candidate exists" as proof of identity.
Source systems restart voucher numbering every year and a filename carries no
year, so with a partial migration, or with that year's A31 among the vouchers
the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt
was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and
invisible afterwards. Cardinality is not identity.
Every batch now declares its fiscal year and candidates outside it are dropped
before the index is built, so no downstream branch can see, count or propose
one. The attach route takes the year for its re-resolution from the TARGET
entry, never from the client, so the check cannot be widened by naming a
different year. Scoping cannot make the year inferable; it makes it asserted,
and the confirm dialog reads it back because it is the one input the files
cannot corroborate.
Four further defects from the same review:
- npm test went red: hoisting the column list into a VOUCHER_SELECT constant
hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and
dropped all eight journal_entries columns out of the guard on the one path
that writes irreversible links. Both selects are inline again, and split:
the provider sweep no longer fetches three display columns it never reads.
- The date guard only caught zero-padded hyphenated dates, so
`2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all
parsed as voucher 2024 or 24. Widened to unpadded components, two-digit
years and space/slash separators; a bare year-shaped number is refused.
- `Verifikation 31.pdf` parsed as series ION: the alternation matched
`ifikat` and left `ion` for the series group. Reordering alone was not
enough (the engine backtracks into it), so the prefix now requires the
word to end.
- The manual-reference box was an unguarded write path: typing a date got
path-split down to a voucher number, marked the row selected, and posted
with override, which skips both server checks, while the row still showed
"Kan inte tolkas". Directory splitting is gone from the parser, the row
status is updated on resolve, and picking a server-proposed candidate no
longer counts as an override, which had disabled the filename check on
exactly the ambiguous rows it exists to protect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): enforce the declared fiscal year on the server
The second adversarial pass refuted the previous fix. The attach route took
the year for its re-resolution from the TARGET entry, which is tautological:
an entry is by construction inside its own fiscal_period_id, so the filter
could never drop it and the year axis was unfalsifiable. Server-side year
enforcement was zero; the declared year existed only as React state and was
never sent. The regression test that "proved" otherwise passed only because
the mock let one journal_entries row report two different fiscal_period_id
values to two different reads, a state Postgres cannot produce. A test that
could not fail.
The attach request now carries the year the user actually reviewed, echoed
back from the plan, and the route asserts it equals the target's own period
BEFORE any other check and including overrides: an override is a statement
about which verifikat, never about which year. Its test asserts that directly
instead of a mock artifact.
Also from the same pass, a UI race that made the confirm dialog lie: FyPicker
stayed interactive while a preview of up to 2000 filenames was in flight, so
the summary and the confirm text could read back a year the plan was not built
from, and a manually resolved row could join the batch from another year
entirely. The wizard snapshots the plan's year, every downstream read uses the
snapshot, manual re-resolution goes through the server's own echoed
plan.fiscal_period_id, and the picker is frozen while a preview runs.
Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids,
no ReDoS found: 2000 hostile inputs in 26ms):
- Day-first and US dates parsed as voucher numbers: `31.01.2024` became
voucher 31, a number that always exists in the year. The guard now covers
both orders.
- `ver 31.pdf` parsed as series VER and came back auto-selectable, while
every spelled-out `Verifikat 31.pdf` correctly yielded a series-less
reference needing confirmation. Same filename, two trust levels, decided
by an abbreviation. `ver` is no longer a series.
Known residual, stated rather than papered over: a scanner's `A4.pdf` or a
`K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10
when that year has them. No parser can separate those from a genuine
reference; they appear in the review table with the target's date and
description.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): make the user actually declare the fiscal year
The third adversarial pass found that the central guarantee of the previous
two commits was fiction. FyPicker auto-selects the newest fiscal period when
nothing is stored, and the wizard passes a page-specific storage key, so that
branch fired on every first use. A user migrating 2023 receipts who never
opened the picker resolved them against the newest year; A31 exists in
essentially every year, so those rows came back `matched`, pre-selected, with
only the confirm dialog between them and permanent links. Every commit message
and code comment claiming "the year the user named" described behaviour the UI
did not have.
FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other
caller changes, and the wizard uses it. The picker starts empty and the batch
cannot proceed until someone picks. A previously stored explicit choice for
this surface is still restored, which is what makes a multi-batch migration
bearable.
Also: a company with zero fiscal periods hit a disabled picker and a disabled
button with no explanation. There is now a line saying why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): close the restore-branch hole and demote collision-prone refs
Round four of adversarial review, two findings, both fixed.
1. `requireExplicitChoice` gated only the newest-period fallback, not the
localStorage restore branch above it, so the "user declares the year"
guarantee held only for a user's first-ever batch. From the second on, the
year was silently pre-filled from an earlier unrelated batch, and in a
multi-year migration last-used is the worst possible default: the user is
by definition moving to a different year each round. The prop now gates
FyPicker's ENTIRE auto-selection block with one outer condition (restore,
the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a
per-branch gate already missed one branch once. It also suppresses the
localStorage write, which fired BEFORE onChange and so recorded picks the
wizard had rejected mid-preview. The wizard drops its storage prefix
entirely: within one sitting reset() carries the year in state, and
nothing survives the session.
2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring
a click for `31.pdf`, which carries MORE voucher evidence in a
single-series company. Two independent review passes flagged the same
inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/
T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/
SCN are cameras; real SIE series are 1-2 chars) still parse and resolve
but are never auto-selected. Demoted, not refused: verifikat A4 genuinely
exists in every migrated ledger, and its real receipt costs one click.
Residual documented: an existing short series plus a small number in an
ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by
filename alone.
Also: the attach route's multipart doc now names the required
fiscal_period_id field, and the stale reset() comment describes the actual
persistence model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): honor override only for unresolvable filenames + review round
Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish
accounting review, compliance swarm).
The one substantive finding (CodeRabbit, major): `override: true` skipped the
filename consistency check entirely, so a crafted client could attach a
cleanly-named file to any same-year verifikat. The resolver now runs on every
request; an override is honored only when the filename is unresolvable in the
declared year (no parse, or no candidate) or already resolves to the requested
target. The shipped UI only overrides unresolvable rows, so nothing
user-facing changes. planAcceptsTarget is renamed planPermitsAttach and
carries the semantics in one place, with tests for both directions.
The Swedish review finding (BFNAR 2013:2 systemdokumentation): the
planPermitsAttach JSDoc still described the superseded derive-the-year-from-
the-target design. It now states the actual control: the route asserts the
caller-declared year equals the target's own period before this function runs.
CodeRabbit minors and nitpicks:
- underlag_confirm_body / underlag_run / underlag_locked_warning use ICU
plural forms in both locales; "1 filer arkiveras" was wrong Swedish.
- The attach and preview route tests mock @/lib/supabase/server per the
repo test guideline.
- fetchVouchersForNumbers narrows to the declared fiscal year at the DB;
the in-memory filter in buildUnderlagPlan remains the enforced truth.
- buildVoucherIndex appends into existing arrays instead of copying per
row: the provider sweep indexes every migrated entry in the company and
per-row copies made that O(n^2).
- The pg test reuses its insertDocument helper instead of a duplicated
INSERT; runAttach clears isLoading in a finally.
Declined, with reasons in DECISIONS.md: message-regex classification of
validateDocumentFile failures (established sibling pattern; validator
contract change is out of scope).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): attach only to posted or reversed verifikat
Second review cycle on PR #1627: the Swedish accounting review's re-run found
that nothing in the attach route verified the target entry's status. The SIE
import RPC posts every entry inside its own transaction, so a draft carrying a
source ref should be unobservable, but the link this route writes is
irreversible räkenskapsinformation, and an invariant enforced in another file
is not one this surface may lean on. Underlag references a verifikation
(BFL 5 kap 6-7 §), so the target must BE one.
Enforced twice: the route rejects non-posted targets with
UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter
to posted/reversed so a draft can never even become a candidate. Reversed
stays attachable: a storno'd original remains räkenskapsinformation and its
underlag belongs on it.
Also recorded as confirmed-intentional (review note, no code change): with
override and an unresolvable filename the endpoint links to any same-company,
same-declared-year, posted verifikat, migrated or not, which mirrors the
existing /api/documents/[id]/link capability. The period-lock error-string
regex note restates a disposition already recorded in DECISIONS.md.
The arcim test's Supabase double learns .in(), which the shared resolver read
now uses for the status filter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4362bffc0c |
fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat (#1621)
* fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat "Skapa verifikat manuellt" in the SkattekontoBookDialog routed to plain /bookkeeping: the user landed on the list with no form, no prefill and no link to the row (reported by a user for a Slutlig skatt event, which has no booking rule by design). The CTA now deep-links to /bookkeeping?skv_tx=... carrying the row's id, date, text and amount. The bookkeeping page opens the Nytt verifikat dialog prefilled (1630 on the correct side per the booking sign convention, balanced counter line with the motkonto left to pick, date and description set) and, once the verifikat is saved (posted or draft), links it back to the skattekonto row via the existing match endpoint. A failed link degrades to a destructive toast pointing at the manual "Matcha mot verifikat" path. The URL params are prefill convenience only: the match route re-validates ownership, ALREADY_BOOKED and ENTRY_ALREADY_LINKED server-side. The parse/build/line-shaping contract lives in core lib (lib/skatteverket/manual-verifikat-prefill.ts, unit-tested) because the bookkeeping page cannot import from the extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skattekonto): keep deep-link payload out of the URL + share the 1630 constant Resolves the PR #1621 review findings in one pass: - Compliance swarm (GDPR Art.5(1)(f), ISO A.8.12): the deep link no longer carries date, text and amount as query params, where they would persist in browser history, access logs and Referer headers. The row payload is staged in sessionStorage, consumed single-use and validated against the opaque skv_tx id, which is all the URL exposes. A missing or mismatched payload degrades to the plain /bookkeeping list; the auto-link itself is still validated server-side by the match route. - Swedish accounting review note: SKATTEKONTO_ACCOUNT ('1630') is now imported by the extension's booking and match libs from the core prefill lib instead of being duplicated, so prefill and server-side booking cannot drift. - CodeRabbit docstring warning: the new lib exports carry docstrings. Storage is injectable (PrefillStorage) so the node-env tests cover the round-trip, single-use semantics, id mismatch, malformed payloads and a throwing privacy-mode storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skattekonto): record the sessionStorage staging window as accepted residual risk The compliance swarm's remaining LOW finding (ISO A.8.12) offers documentation as its remediation path: an XSS attacker already reads the full ledger via the session's authenticated APIs, so the sub-second sessionStorage staging window adds no capability worth a server-issued token roundtrip. Recorded in the lib header and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b556475b01 |
fix(import): migration preview and theater read all fiscal years, not just the newest (#1614)
The /sie-data route parsed only the newest fiscal year's SIE file for the import preview and the returned SIEData.parsed. Mid-year provider exports have few or zero vouchers in the newest year, so the first real Fortnox migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer" and drew an almost-empty migration theater while the import itself landed all 4153 vouchers from the older files. - New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean whole-dataset merge (accounts union first-wins, vouchers concatenated, fiscal years union oldest-first re-indexed newest=0, balances and issues concatenated, dimensions deduped), with unit tests. - /sie-data parses each file exactly once, builds the preview from the merged parse and returns parsed: merged; response shape unchanged. Validation stays newest-file-only so no previously accepted dataset is newly rejected. - /preview drops latestOnly and computes sieStats from the merged parse: the connect step's "Hittade X konton och Y verifikationer" line renders from THESE stats, so this is where the founder-visible count was lying. - The migration theater spreads its account waves across ~10s and births an additional wave on each real step label during the SIE phase (progress <= 55), through a shared rate-limited gate, so the canvas keeps performing over a multi-minute run. Narration labels and progress remain the wizard's real values; reduced motion unchanged. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5984652e47 |
fix(skatteverket): request the AGI kvittens scope and stop misdiagnosing the 403 (#1596)
* fix(skatteverket): request the AGI kvittens scope, not just the inlamning one
AGI is backed by two SKV APIs and each needs its own scope, but DEFAULT_SCOPES
only carried `agd`. That covers arbetsgivardeklaration/inlamning (POST underlag,
kontrollresultat, spara, skapaGranskningsunderlag), so a filing submits and
signs perfectly. The kvittens read lives on hanteraredovisningsperiod, which
requires `agdredovisningperiod`, so the flow died on its very last step with
403 {"error": "The required scopes are not authorized"}.
Confirmed against production: the same APIGW client id and secret reach the
bearer check on both APIs (401 "Token has been revoked" from each with a bogus
token), proving both subscriptions exist and the gateway contract was never the
problem, and SKV's application registration lists `agd`, `agdredovisningperiod`
and `momsdeklaration` as three separate scopes.
The body is identical to the APIGW subscription gap of #973, which is why
api-client's classification cannot distinguish the two from the string alone;
that split still needs the gateway-side check, so it is left as is.
Note the spelling `agdredovisningperiod`: no genitive s, exactly as SKV
registers it. Pinned with a scope-set regression test, since this is the third
time a scope has gone missing (#431 removed `ska` the same way) and the damage
is always invisible until a real filing fails.
The AGIPanel missing-scope banner now checks both scopes. It only looked for
`agd`, so the token shape that actually hurts, one that gets all the way
through signing before failing, produced no warning at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(skatteverket): stop blaming the APIGW subscription for a missing token scope
`{"error": "The required scopes are not authorized"}` has two causes and the
gateway never says which: our APIGW client has no subscription for the API
(#973), or the token lacks the scope that API requires. The message named only
the first, so a real production filing spent its debugging time in
Utvecklarportalen while the actual fix was a scope the code never requested.
The message now names both knobs, and says WHICH service refused. That last
part is what was really missing: the sibling inlamning API kept working
throughout, so "Skatteverket denied the call" pointed at nothing. Ruling the
two apart still needs the out-of-band test (call the API with a deliberately
invalid bearer and the same Client_Id: a subscription gap fails at the gateway
with this body, a scope gap reaches the bearer check and answers 401), which is
now documented at the predicate instead of living in someone's memory.
Classification is deliberately unchanged. ACCESS_DENIED stays out of
RECONSENT_ERROR_CODES: the scope case does need a reconnect, but only after the
scope exists, so an automatic reconsent would rebuild the self-perpetuating
banner of #1155.
This reverses one specific decision from #1250, which removed the
SKATTEVERKET_SYSTEM_SCOPES mention on the reasoning that the gateway rather
than the scope list had refused. That reasoning assumed the body could tell
them apart. It cannot. The test asserting the omission is inverted, with the
reason recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(salary): make the AGI kvittens-scope notice dismissible
The two AGI scopes get different treatments in AGIPanel: a token
without agd keeps the hard reconnect nudge, but a token missing only
agdredovisningperiod gets its own softer, dismissible notice. Every
existing hosted token lacks the new scope, and until Skatteverket's
application registration carries it a reconnect mints the same grant
again (SKV silently drops unregistered scope names), so a hard
reconnect demand would be the #1010 un-clearable banner, with each
futile attempt costing a BankID round-trip. Dismissal persists per
granted scope string: a futile reconnect stays quiet, a widened grant
re-evaluates from scratch.
Also asserts the shared prenumeration/scope message and the refused
API path on the 401 contract test, matching the 403 test (review nit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>
* fix(salary): point the kvittens-scope notice at Skatteverket's own e-service
The compliance review flagged that dismissing the notice could leave a
filer without a software-side path to the receipt. Retrieval never
depended on the notice (the kvittens cron retries server-side and the
period's status row stays unconfirmed until a kvittens lands), but the
copy now also names the fallback that always works: verifying the
receipt in Skatteverket's Arbetsgivardeklaration e-service.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>
* fix(salary): use the ladder radius on the kvittens notice
The radius ladder guard landed on main after this notice was written;
rounded-md is dead vocabulary, bordered boxes are rounded-lg.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(salary): scope the kvittens-notice dismissal to the employer
The localStorage key carried only the granted scope string, so on a
shared browser one company's dismissal hid the notice for every other
company with an identical grant. Skatteverket tokens are per company,
so the key now includes arbetsgivare alongside the scope string; the
same-grant reconnect behavior per company is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
|