c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
157 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b68c082ef5 |
feat(bank-sync): close the F2 report: gap backfill, consent and paused chip states, agent-triggered sync (#2165)
* fix(bank-sync): cron backfills the gap since the last successful sync The daily incremental sync always asked the bank for the last 7 days. Any pause longer than that (a lapsed subscription paid again, a consent renewed after expiry, an outage) silently lost the days in between: the connection came back, looked healthy, and the missing transactions never arrived. The lookback now widens to cover the gap since last_synced_at plus one day of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more asks for strategy=longest like the manual sync route does. Dedup via external_id makes the overlap harmless. First syncs keep their 90-day path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip warns seven days before a bank consent expires The transactions-page chip only reacted once a connection was already dead (expired/error) or had gone stale. A consent that is about to end looked healthy until the morning it stopped syncing. New "expiring" state when a live connection's consent_expires is within seven days, the same threshold as the consent-expiry email in the sync cron. Precedence: attention, expiring, stale, healthy. getChipState moves to lib/transactions/bank-sync-chip-state.ts so the precedence is unit-tested; the component keeps the rendering only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip says paused when the subscription lapsed The daily cron filters connections by the bank_sync capability, so a company whose trial or subscription ended keeps status=active rows with a frozen last_synced_at. The chip read that as "stale, check the connection", which sends the user to re-authorise a connection that is perfectly alive. 56 of 191 active connections on prod were in this state on 2026-09-01. New "paused" state, ranked above everything else, when the company lacks bank_sync: hosted points at billing, self-host at the connector key, the same split BankSyncNowButton already makes. getChipState takes an options object so the clock stays out of render (react-hooks/purity). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(api): agent-triggerable bank sync in v1 and MCP Closes the first wish in the F2 report: an integration could read bank data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/ {connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner (extensions/general/enable-banking/lib/trigger-sync.ts). Cost is bounded structurally, not by policy: the window is never caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on instead of retrying), and a failing connection is throttled per process by attempt time. A dead session is flipped to expired with a remediation that hands the user the connect link: no API call revives a consent. Gated on bank_sync like gnubok_connect_bank; scope transactions:write. Registry, scope map, load-routes, spec snapshot and the generated accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes added to the structured-error registry. The web Synka-nu route is left as is (see DECISIONS.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * test(bank-sync): use the options object in the remaining chip-state calls Four multi-line calls still passed the clock positionally after getChipState moved to an options object; tsc flagged them (vitest did not, the extra argument was ignored at runtime). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(api): address skeptic findings on the agent-triggered bank sync Three refutations from the pre-publish skeptic pass: 1. Core imported the extension. The v1 sync route pulled the runner straight from @/extensions, which the core-build gate rejects and which left a live bank endpoint on zero-extension builds. The route now resolves it through the registry's services channel against a contract in lib/bank-sync/trigger-sync-contract.ts (same pattern as the Skatteverket read service) and answers EXTENSION_DISABLED when the extension is absent. 2. The idempotency cache stored the handler-level 429. A same-key retry after Retry-After, which is the documented retry, replayed the stale cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer caches 429 responses; regression test added. The endpoint's pitfall no longer claims Idempotency-Key is mandatory (it was never enforced). 3. Two cron tests read the clock twice and failed whenever a millisecond passed between the reads. They now pin the clock with fake timers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(bank-sync): durable cooldown lease and review wording Resolves the PR #2165 review findings in one pass. Superagent P1: the attempt throttle was a process-local Map, so two agent calls on different serverless instances (or a retry after a cold start on a failing connection) could each bill an Enable Banking call, contradicting the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until (migration 20260902150000), claimed with one conditional UPDATE before the bank is called; Postgres row locking makes exactly one claimer win, the rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on success and failure. Tests cover the claim order, a failed attempt seen from a second instance, a lost race, and an expired lease. CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag" (daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be today); the cooldown pitfall on the v1 endpoint, the MCP description and the in-band cooldown instruction now say a cooldown can follow a failed attempt and tell the agent to compare last_synced_at before deciding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): lease claim as a literal filter for the schema guard CI's no-phantom-columns guard counts runtime-built query expressions and its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')` claim added one. The column now defaults to epoch (NOT NULL), so "never claimed" is just "expired long ago" and the atomic claim is a single literal `.lte('sync_lease_until', now)` the guard can check. Migration is unshipped (same PR), so it is edited in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): runner verifies company membership before the lease Superagent (round 3): the MCP path reached the shared runner without a membership check of its own. Both callers do enforce it upstream (withApiV1's company resolution and resolveMcpCompanyContext in the MCP dispatcher), but the runner writes transactions and bills a bank call, so it now checks company_members itself, before the cooldown and the lease claim, and answers NOT_FOUND for a non-member. The viewer check that was buried inside the sync block moves up with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f1230282a9 |
feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings A company running several bank accounts (main bank on A, company card on M, both imported via CSV) could not route each account's bookings into its own series: every bank_transaction booking took the single company-wide default from default_voucher_series_per_source_type. - cash_accounts.voucher_series (nullable, single letter): per-account override, editable under Inställningar → Bokföring → Verifikationsserier per bankkonto (new PATCH /api/cash-accounts/[id]). - resolveCashAccountVoucherSeries(): step 2 of the resolution order (explicit pick → account override → per-type map → A). Wired into the book route and createTransactionJournalEntry, which covers categorize, the agent, pending operations and the v1 API. - Booking dialog gets the series picker, seeded from the server via /voucher-sequences/next?source_type&cash_account_id so dialog and route can never disagree. An unresolved embedded picker omits voucher_series so a stray 'A' never overrides the account's series. Scope: bank_transaction bookings only. Invoice settlements matched from the bank keep their payment series; bulk-book resolves inside its RPC (see DECISIONS.md). Migration applied to staging as 20260902121420. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh * fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish compliance review): - Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series is a behandlingsregel that outranks the audited per-type map. New trigger audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in the report, pg-real test. Applied to staging as 20260902124513. - Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a verifikationsserier_regler block with the resolution order and the two exceptions (invoice settlements, samlingsverifikat); the per-account mapping itself is in data/cash_accounts.json. - Settings picker uses the same closed list as the manual verifikat form (presets plus letters already in use) instead of all 26 letters; strings moved to messages/sv.json and messages/en.json. - /voucher-sequences/next applies the account override only for source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot show a series the entry will not get. - Book route resolves the series from the account the row ends up on after a stranded-row repoint, not the stale one. - PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a Postgres cast 500; the series lookup logs a warning when it fails open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f266c386f3 |
chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4c76fb10d7 |
feat(transactions): "Ta bort underlag" detach action on a transaction (#2132) (#2144)
* feat(transactions): "Ta bort underlag" detach action on a transaction (#2132) Wrong receipt pinned, no way back: the DELETE /api/transactions/[id]/attach-document route and its tests already existed, but nothing in the UI called it. This wires it up, frontend only. - Inbox card and history list: "Ta bort underlag" in the row menu, shown only for writers on unbooked rows that carry a pin (canDetachDocument helper). - Attach dialog: a small "Ta bort underlag" link beside the already-attached hint, the one place the app previously admitted a doc was pinned. - Page: handleDetachDocument confirms (useDestructiveConfirm, warning), then DELETEs; 200 clears document_id in local state (list, dialog snapshot, and the inbox card's optimistic override via a -unlinked window event) and toasts; 409 renders the route's Swedish BFL message verbatim; other errors map through get-error-message. - Strings under tx_detach in sv.json and en.json. - Tests: gate hidden when booked / read-only / no pin / no handler; 409 rendered unchanged; wiring and locale assertions. Out of scope, follow-up: MCP detach tool (new pending-op type + CHECK migration), detaching from the inbox for non-email docs, and clearing invoice_inbox_items.matched_transaction_id on detach so the doc is offered again by inbox-available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): clear the inbox back-link when detaching underlag (#2132) Skeptic finding on PR #2144: DELETE attach-document nulled only transactions.document_id and left invoice_inbox_items.matched_transaction_id pointing at the transaction. propagateUnderlagForBookedTransaction selects on exactly that column at categorize / book / bulk-book time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §), and the doc never reappeared in inbox-available for re-matching. The route now clears the back-link for the detached document, scoped to items not yet consumed by a verifikat (created_journal_entry_id null), mirroring the invoice-inbox extension's unmatch. Best-effort like the POST side: the pin removal is the primary effect. Three DELETE tests cover the filters, the no-pin case, and a failing unlink. DECISIONS.md and the PR body record the accepted bulk-booked-row limitation in the history list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): detach reports a failed inbox unlink instead of success (#2132) Swedish compliance review on PR #2144: the inbox back-link cleanup was fire-and-forget, so a failed UPDATE returned 200 while leaving exactly the stale matched_transaction_id that re-anchors a detached document onto the next verifikation (BFL 5 kap 6-7 §). The unlink is now scoped by transaction only (the unique index on matched_transaction_id means at most one item points here, and a stale item from the replace path would re-anchor just the same), runs even when nothing was pinned so a retry is idempotent, and a failure answers 500 with an honest Swedish partial-failure message, mirroring the POST side's propagation failure. Tests updated accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): release inbox back-link before a compare-and-set pin clear (#2132) Review findings on PR #2144, one pass: - CodeRabbit (major): DELETE cleared the pin and then released the inbox back-link scoped by transaction, so a POST landing in between could end up as "new doc pinned, its inbox item unlinked". The release now runs FIRST, and the pin clear is a compare-and-set on the document that was read (.eq document_id, or .is null when nothing was pinned). Zero rows answers 409 "ändrades samtidigt" and keeps the newer pin. A failed release returns 500 before anything changed, so a retry is trivially idempotent. - Compliance swarm (A.8.15): the unlink failure log carried the raw driver error; it now logs errorCauseTag() only. - CodeRabbit docstring check: JSDoc on handleDetachDocument. Tests: order of the two writes, CAS filters for both pinned and empty states, 409 on concurrent re-attach, coded-cause logging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
43341aa55c |
feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes Click one checkbox, shift-click another, and every row between them takes the clicked row's new state, the way mail clients work. Turns a 20-row bulk selection into two clicks. New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor and applies the range over the rows as currently rendered, so it follows filtering, sorting and paging rather than the underlying data order. A shift-click with no valid anchor (first click, or the anchor filtered away) degrades to a plain toggle. Select-all and clear reset the anchor. Wired into the 8 selection surfaces: transaction inbox and skattekonto inbox (separate ranges, since the two row types book through different endpoints), journal entry list, invoices, supplier invoices, orders, pending operations, invoice inbox workspace. Radix' onCheckedChange carries no mouse event, so each row records shiftKey from the click that precedes it; the checkbox cells get select-none so shift-clicking does not smear a text selection. The pure range rule is unit tested (10 cases: both directions, range unselect, anchor invalidation, rendered-order independence). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y * fix(ui): void the range anchor on an empty selection, keep placeholders out Review findings from CodeRabbit and the skeptic pass, all in the new range-selection feature: - Clearing a selection left the anchor behind, so the next shift-click extended from a row the user could no longer see selected (click a row, press "Rensa markering", shift-click 30 rows down, get 30 rows). The explicit resetAnchor() calls only covered the clear paths that were wired by hand; several others (period change, filter change, post-bulk success, "Avmarkera") were not. An empty selection now counts as having no anchor, which covers every clear path including ones added later. - The invoice inbox passed optimistic upload placeholders into visibleIds even though they render no checkbox. Safe today only because placeholders are always prepended; filtering them out makes the invariant local instead of depending on insert order elsewhere. - pending: "Godkänn alla" pre-selects a non-empty set, so it resets the anchor explicitly. Two existing tests used a fixture the UI cannot reach (an anchor with an empty selection); they now start from the state a real anchor implies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ca12b1855e |
fix(connect): PR #1758 CodeRabbit follow-up: connector status hardening, i18n strings, doc alignment (#2098)
* fix(connect): PR #1758 CodeRabbit follow-up: harden connector status, i18n the connector-mode strings, align docs - getConnectorConfig() rebuilds baseUrl as origin + path: userinfo, query and fragment are stripped (warn-logged without the raw value) so nothing secret-shaped pasted into GNUBOK_CONNECT_URL survives into the /api/connector/status echo or the derived proxy URLs (CWE-200) - /api/connector/status responds Cache-Control: no-store on both branches (key prefix + wiring layout out of shared browser caches, CWE-525) - CWE-319 thread verified as no-change: both connector-mode helpers derive from getConnectorConfig(), which fails closed on non-https - SkatteverketConnectPanel tooltips and BankSyncNowButton gate/upsell strings moved to messages/sv.json + messages/en.json keys - DECISIONS.md: MD037 fix on line 1146 (backtick the glob), line 1147 reworded to grants-written-wiring-pending, decision lines appended (incl. declining the UpgradeNote children-append suggestion) - docs/SOVEREIGN.md availability wording aligned with SELF-HOSTING.md: infra merged, keys issued manually on request, client wiring pending Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKZUp1nePr8sVDoLkMSxbS * docs(connect): skeptic follow-up: bank client wiring is merged (#2094), SKV pending, no keys issued until it lands Skeptic refutation on PR #2098: SOVEREIGN.md claimed the services 'do not carry traffic' while this branch already contains #2094 (EB client proxy routing), and 'issued manually on request' contradicted the standing no-key-before-full-PR6b rule while skatteverketConnectorMode() has no client consumer yet. SOVEREIGN.md, SELF-HOSTING.md and DECISIONS.md line 1147 now all say: bank client wiring merged and carries traffic with a key, Skatteverket client wiring ships in a following release, keys are not issued until it lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKZUp1nePr8sVDoLkMSxbS --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5f81c0638c |
fix(ui): make row selection checkboxes always visible at muted opacity (#2093)
* fix(ui): make row selection checkboxes always visible at muted opacity User feedback: bulk-select checkboxes hidden until hover are "sjukt pilligt": an invisible 16px target forces a precise hover-then-aim per row, and bulk selection is a primary workflow on list pages. New CHECKBOX_REVEAL_CLASS in dry-table.tsx: rest at opacity-50, solid on row hover, focus, coarse pointers, and checked state. Applied to the 8 selection-checkbox sites (transaction inbox, skattekonto inbox, journal list, invoices, supplier invoices, orders, pending, invoice inbox workspace). Also fixes a touch bug: TransactionInboxCard and SkattekontoInboxCard lacked pointer-coarse fallback, leaving unselected checkboxes permanently invisible on touch devices. Row action controls (chevrons, quiet links) keep HOVER_REVEAL_CLASS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y * fix(ui): meet WCAG 3:1 on resting selection checkboxes via border-foreground Skeptic finding: the checkbox primitive's border-input is ~1.4:1 against the page, so the new opacity-50 resting state composited to ~1.2:1, under the 3:1 non-text contrast minimum design.md commits to. Putting border-foreground unconditionally on the 8 reveal checkboxes lands the resting border at >=3.4:1 in every theme (light 3.41:1, dark 4.24:1, white-label palettes similar) and the hover/solid state at ~17:1. The border class lives at call sites, not in CHECKBOX_REVEAL_CLASS, because the constant is sometimes applied to a borderless wrapper (InvoiceInboxWorkspace); documented in dry-table.tsx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
50f13cf198 |
feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint (#1758)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance) Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted instance with a `skatteverket`-scoped connector key can now run the BankID consent, file VAT/AGI and sync skattekonto through Arcim's registered Skatteverket client; the SKV tokens are returned to the instance and stored (encrypted) there. - lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data helpers (authorize URL, code/refresh exchange with Arcim's client secret, the four backing-API base URLs, the API-gateway Client_Id/Client_Secret headers). Core can't import @/extensions/, so this duplicates the extension's endpoints/scope set (one integrator = Arcim), mirroring the EB JWT relocation. - app/api/connect/skv/oauth/authorize-url: builds the authorize URL against OUR registered redirect_uri + a signed connector state, per-company SKV connection quota, pending ledger row. - app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens to the instance; the ledger keeps only sha256(access_token) + sha256(refresh_token). - app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto / agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the token hash against the ledger, adds Arcim's gateway credentials (never exposed to the instance), forwards. Same per-key + global budget as bank. - The Skatteverket extension /callback gains the connector branch (isConnectorState -> 302 back to the instance; code never exchanged there). - Docs (SELF-HOSTING: SKV connector live) + DECISIONS. Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean; no-phantom-columns held at 380 (literal update branches). Not in this PR: instance-side wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint Sovereign plan WS3 PR6 (enablement layer), stacked on the SKV broker (#1757). - docker/extensions.self-hosted.json += enable-banking, skatteverket: a connector-key self-host now ships the bank + Skatteverket extensions; a key with the matching scope makes them work, without one they show the existing capability_blocked upsell (unconfigured extensions no-op). - lib/connect/instance/upstreams.ts: the connector-mode seam. An upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials). Hosted always has own credentials, so hosted is provably never in connector mode: the guard is what keeps hosted byte-identical. Base URLs GNUBOK_CONNECT_URL/api/connect/{bank,skv}, headers X-Connector-Company / X-Connector-Upstream-Authorization. - GET /api/connector/status: the operator's wiring view (self_hosted, per upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). Hosted returns self_hosted:false. - Docs (SELF-HOSTING: status endpoint + extensions ship in the image), DECISIONS. Tests: connector-mode detection matrix (off without a key, off with own creds incl. the _PRODUCTION EB variants, on via the proxy, CONNECT_URL override) + status route (self-host vs hosted, unconfigured, per-upstream mode, prefix-not-key). 83 connect/connector tests green; tsc, guards, lint. DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end to end, touches the live consent path): wiring the EB api-client / consent callback and the SKV oauth / api-client to call the proxy in connector mode, and the "Synka nu" settings row (UI, needs visual sign-off). The seam + preset + status route make PR6b a contained follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(connect): upstreams seam reuses lib/entitlements/own-credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * test(connector): status route tests pass the Next params argument (post-merge withRouteContext signature) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * docs(self-host): collapse the re-duplicated connector section; correct the crontab generator's preset comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(self-host): UpgradeNote and SKV tooltip name the connector key, never the hosted subscription; SOVEREIGN.md updated to merged reality Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(self-host): BankSyncNowButton gate copy branches like UpgradeNote (connector key, not hosted billing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com> |
||
|
|
d11d0a2e90 |
feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553 |
||
|
|
57d4359d1a |
feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5720632832 |
fix(transactions): stop inbox row markers overlapping Belopp on mobile (#2003)
* fix(transactions): stop inbox row markers overlapping Belopp on mobile The inbox row's inline markers (pre-migration marker, edited badge, 1930<->1630 badge) are shrink-0 inside a max-w-0 description cell, so on narrow viewports they overflowed the cell and painted over the amount column. Gate them behind md: (the same pattern TransactionHistoryList already uses), clip the description cell with overflow-hidden as a backstop, and surface the pre-migration context in the row foldout so it stays reachable on mobile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQkngviNKUTAwA5dhi2ff1 * fix(transactions): keep edited-title state reachable on mobile without original name CodeRabbit review: the PATCH route can set title_edited_at while original_description is null, and below md both the inline marker (now hidden) and the foldout line (required originalName) disappeared. The foldout now opens on title_edited_at alone and falls back to a generic edited-title line when the original bank name is missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQkngviNKUTAwA5dhi2ff1 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c981384a0e |
fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow Three gaps around the agent sheet during booking on Transaktioner: - The AgentTrigger bubble lived inside #dash-shell, so the inert that non-modal dialogs set on the shell made the assistant impossible to OPEN once a booking dialog was up. Moved outside the shell (it is position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so it stays clickable, below dialog content (z-50). Under true modal dialogs Radix's body pointer-events lock keeps it dead as before. - Wide dialogs centered on the full viewport while the docked sheet (z-60) covered their right edge, hiding e.g. the Granska button. DialogContent now centers in the space left of --agent-dock-w, and the wide booking/review variants cap their width against it. - Step 1 of the flow (template picker, QuickReviewDialog) was still fully modal, so the assistant was dead there. Both are now non-modal with DialogVeil + a shared ref-counted useDashShellInert hook (also replacing the duplicated inert effects in TransactionBookingDialog and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they hold no half-filled form. Clicks in the agent sheet or its trigger never dismiss any dialog: data-agent-ui counts as inside, same mechanism as data-dialog-companion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): dialog centering reads a docked-only sheet width variable Skeptic review refuted the first cut: globals.css seeds --agent-dock-w at the 10px frame gutter on :root by design, so the 0px fallback in the new dialog centering never applied and every dialog sat 5px left of center (full-bleed dialogs clipped 5px off-screen). Introduce --agent-sheet-w, set inline by AgentSheetProvider only while the sheet is docked and removed otherwise, so the 0px fallback is real: sheet closed or floating renders byte-identical to the old left-[50%] and old max widths. Also cap the template picker and QuickReview's narrow variant, which could clip off-screen left on narrow desktops with the sheet docked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
98aff7c577 |
fix(transactions): keep booking dialog open while typing in the agent sheet (#1966)
* fix(transactions): keep booking dialog open while typing in the agent sheet
TransactionBookingDialog was a default modal Radix dialog; the agent
sheet is a fixed z-[60] panel portaled outside its DOM, so a click in
the assistant's text field counted as an outside interaction and
dismissed the dialog, losing the half-filled booking.
Apply the established non-modal convention (NewInvoiceDialog /
NewJournalEntryDialog): modal={false} + DialogVeil, with escape and
outside-dismiss prevented. Closing is explicit via the header X.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): restore page modality via inert while booking dialog is open
Skeptic review caught that the non-modal convention was ported
incompletely: NewInvoiceDialog pairs modal={false} with an inert effect
on #dash-shell. Without it the background page stayed keyboard-, AT-,
and (mobile nav) tap-reachable behind the veil, so a stray Shift+Tab
plus Enter could navigate away and destroy a half-filled booking.
Also corrects the comment that cited NewJournalEntryDialog as non-modal
precedent (it is plain modal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
fdb5f6f891 |
feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
3ee3565d6d |
perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)
Final consumer migration of the responsiveness plan: the 35 files still fetching fiscal periods, settings, accounts, cash accounts, dimensions or templates on their own now read lib/reference-data, and every client write site invalidates the shared cache instead of refetching locally. Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period snapshotted once per company so a revalidation cannot reset dates being edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager, EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog, InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager, DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id]) reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached helpers are deleted. Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per company load), use-account-names, FiscalYearGapNotice, OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import page (invalidates accounts + periods after a SIE execute), customers list, invoices list + detail, pending, salary employee, asset dispose, year-end and periodisering pages (invalidate periods after closing), reports DimensionPnlView (its pivot picker read the wrong payload key and was always empty; it now populates), SkatteverketPanel, TemplatePicker, ArticleForm (vat_registered). Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog (init reduced to the credit-note lookup + catalogue, proposal and voucher preview fire on open when cached; a local getSession replaces the network getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace, ArcimMigrationWorkspace (invalidates after each SIE import step), enable-banking AccountPickerDialog. raw-reference-fetch ratchet: 35 -> 0 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4560ccbfc9 |
perf(forms): supplier-invoice form, register forms and review dialogs read the session cache (#1938)
The supplier-invoice editor issued four requests on every mount (suppliers, accounts, settings, fiscal periods) and defaulted vatRegistered=true, entity type and rounding until /api/settings landed, so the moms controls visibly flipped. The register forms fetched the whole chart of accounts to fill one konto combobox, and each transaction review dialog refetched accounts, cash accounts or settings per open. - use-supplier-invoice-data: thin composition of useSuppliers, useAccounts, useCompanySettings and useFiscalPeriods; the settings-driven gates come from a pure deriveSupplierInvoiceDefaults() (tested) instead of state that flips when the fetch returns; the per-invoice öresavrundning toggle is the one local override. Inline supplier create invalidates the shared list instead of patching local state. - SupplierForm, ArticleForm (posting accounts), QuickReviewDialog, InvoiceMatchDialog, supplier-invoices/[id] (payment dialog chart): useAccounts; ArticleForm's inline account create invalidates the chart. - BulkBookDialog, MatchVoucherDialog, DuplicateBookingDialog: cash accounts from useCashAccounts (resolveAccount over the cached list; an empty list still resolves to 1930 with the fallback note). - QuickReviewDialog, BulkBookDialog, NewEmployeeDialog, customers list (default payment terms), salary run page (payment format, bank, IBAN, dimensions): derived from useCompanySettings; the salary page's post-settings-modal refetch becomes a cache invalidation. raw-reference-fetch ratchet: 45 -> 35 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
567fae654c |
perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its purest form: Bokför (TransactionBookingDialog + the embedded JournalEntryForm) issued five requests on every open (fiscal periods, accounts, settings, cash accounts, then the voucher preview once the first two had landed), Nytt verifikat the same minus one, BookDirectlyDialog four, and the template dialogs two. Each Radix dialog unmounts on close, so every reopen paid the full price again, and several fields visibly flipped: the bank line seeded '1930' then rewrote itself, the series defaulted to 'A' until settings arrived, the period select was empty. All of them now read lib/reference-data (seeded by the dashboard layout): - JournalEntryForm: periods, accounts and settings from the hooks; dimensionsEnabled derived, not fetched; the voucher-number preview is keyed on the entry date (the route resolves the period from it) so it fires as soon as the series is known instead of after the period fetch; after activating accounts it invalidates the shared accounts cache; the create-period dialog callback invalidates the periods cache. - TransactionBookingDialog: settlement account and its name derived with useMemo from the cached cash accounts; the form mounts on the first paint. - BookDirectlyDialog: cash accounts, periods and accounts from the hooks; the '1930'-then-rewrite disappears because the resolved account is known on the first render. - TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates (and periods) from the hooks. - BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create) invalidate the corresponding cache entries so every picker sees the change at once. - fetchers.ts: booking templates are booking_templates rows (BookingTemplateLibrary), not the static BookingTemplate shape. Per open: Bokför 5 requests -> 0 blocking (voucher preview is a non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly 4 -> 0, Mall 2 -> 0, template pickers 1 -> 0. raw-reference-fetch ratchet: 51 -> 46 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9a56b7aff9 |
perf(bookkeeping): fiscal-year pickers and cash accounts read the session cache (#1934)
First consumer migration onto lib/reference-data. FyPicker and
FiscalYearSelector (14 consumer surfaces, 47 fiscal-period fetch sites
before this series) now read useFiscalPeriods(); with the layout seed the
restore of the persisted scope runs in the first effect tick and onReady
fires on mount instead of after a round trip. Their restore rules are
extracted into a pure resolveInitialFiscalScope() (lib/reference-data/
fiscal-scope.ts) so the two pickers cannot drift apart again, and the
restore runs once per company load, not on every background revalidation.
- /reports: the static catalog renders immediately; only the "no fiscal
year" empty state waits for the picker (previously six skeleton bars
until /api/bookkeeping/fiscal-periods resolved).
- JournalEntryList (/bookkeeping): resolves its initial scope from the
cached list instead of its own fetch; the saved-scope shortcut still
unblocks the entries fetch first when nothing is cached, and resolution
is guarded to once per company so a revalidation can never snap a
deep-link "all years" visit back to the stored year.
- /transactions: the account chooser reads useCashAccounts({ enabledOnly })
(seeded) instead of fetching /api/cash-accounts on every visit; the bank
sync button invalidates that entry after a sync.
- STORAGE_KEY_PREFIX / ALL_YEARS_VALUE move to a dependency-free
fiscal-year-storage.ts (re-exported from FiscalYearSelector) so lib/ code
can import them without a React component.
raw-reference-fetch ratchet: 55 -> 51 files.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0bb482bf6e |
feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader Proposal views (AI suggestion, static template, counterparty template with or without a line pattern) previously offered only accept-or-start-over: the verifikation preview was pure rendering and the only line-editable path was library templates. This adds an "Andra rader" affordance to the proposal view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK amounts, VAT legs, exactly what the preview shows) into TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing the same initialLines mechanism library templates already use. - lib/bookkeeping/proposal-lines.ts: line computation extracted from JournalEntryPreview into computeProposalLines() (single source for preview and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping to the JournalEntryForm prefill shape. The settlement leg is flagged so the booking dialog swaps in the transaction's resolved cash account and stamps currency metadata, mirroring buildInitialLinesFromTemplate. - JournalEntryPreview now renders computeProposalLines() output unchanged. - TransactionBookingDialog accepts proposalLines (takes precedence over preselectedTemplate); the booking still goes through JournalEntryForm's normal manual validation and the engine, no validation bypassed. - Ore rounding funnels through roundOre(); guard baseline ratcheted down. - New strings in messages/sv.json and messages/en.json (tx_quick_review). - Unit tests for all three proposal branches incl. VAT legs, reverse charge, multi-line patterns, 3740 rounding diff and FX metadata. Fixes #1878 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings) Three skeptics refuted the first cut of #1878: the extracted preview math was a lossy approximation of the engine, and making it bookable made every loss a real booking defect. This commit closes each refuted scenario by mirroring the exact engine path per proposal branch: - Balance: VAT is single-rounded and the net leg is gross minus that VAT (transaction-entries.ts semantics). Independently rounded net+VAT went off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94), prefillling an unbookable verifikat. - 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via resolveExplicitVat before computing lines, so an explicit no-VAT choice prefills no VAT line instead of re-deriving the 25% category default into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments). - Ore parity: engineRound (plain Math.round(x*100)/100, matching the engine) replaces roundOre where the engine is naive; roundOre kept only where the engine uses it (category VAT leg). No more 1-ore drift between preview, prefill and the booked verifikat (8.62 RC, 34.30@12%). - Legacy counterparty pairs: new counterpartyLegacy mode mirrors the legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair (previously dropped: an RC expense would have booked without fiktiv moms, understating rutor 30/48), VAT on expenses only, income gross, and sign-mismatched matches mirrored like buildLegacyMismatchResult. - Pattern mirror: sign-mismatched line patterns flip learned sides like buildMultiLineMappingResult; ratio allocation filters business/tax types. - Entity accounts: static template accounts resolve debit/credit_account_ab for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer previews or books EF-only accounts like 2013. - Settlement swap: only a literal-1930 settlement leg is swapped to the resolved cash account (applySettlementAccount parity); learned non-1930 money legs (1510/2440/2890/19xx) stay authoritative. - FX: QuickReviewDialog hands its enriched transaction row to the booking dialog so the settlement leg's exchange_rate metadata matches the rate the SEK amounts were computed with. 34 unit tests incl. every skeptic counterexample; guard baseline ratcheted to 622 (below main's 626). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation) Two independent skeptics refuted the pattern branch: the engine books the money leg on the counterparty template's learned legacy account (credit for an expense, debit for an income, mirror-swapped, falling back to 1930), while the preview/prefill defaulted to 1930. A SIE-learned pattern settling on 2440 showed kredit 1930 in the preview but booked kredit 2440 on confirm. QuickReviewDialog now passes the learned pair raw (no entity resolution, engine parity) and computeProposalLines selects the settlement account exactly like buildTransactionEntryLines; the literal-1930 swap to the resolved cash account is unchanged. CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740 rounding line keeps the engine's business-side placement for both diff signs (parity contract; an unbalanced set is rejected at commit), and the naiveOreRound baseline stays at 622 (engineRound is a documented parity exception). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd46d936f3 |
fix(bokforing): account search results in the booking dialog search field (#1877) (#1889)
* fix(bokforing): account search results in the booking dialog search field (#1877) The search field in the "Bokfor transaktion" dialog only matched template metadata, so typing an account number or name (e.g. active 5460 Forbrukningsmaterial) gave zero hits and no path to booking; the real account search was only reachable via the discreet "Bokfor manuellt" link. - TemplatePicker now also searches the company's active chart of accounts (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton" result group; picking one routes into the same manual booking flow as "Bokfor manuellt" with the account prefilled on the counter line. - New buildActiveAccountIndex helper indexes chart rows active-only, so deactivated accounts never surface as bookable results. - searchTemplates additionally prefix-matches all-digit tokens against a template's business account (debit for expense, credit for income, both legs for transfers, AB variants included); the settlement leg is deliberately excluded so "1930" does not light up every template. - Library template search prefix-matches line accounts on all-digit queries. - Empty search results show a hint pointing at "Bokfor manuellt" for accounts outside the active chart; placeholder now says "Sok mall eller konto...". New strings in both messages/sv.json and messages/en.json. Fixes #1877 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokforing): match only business lines when digit-searching library templates (#1877) CodeRabbit review on PR #1889: the all-digit library-template match also hit settlement and VAT lines, so searching "1930" lit up every user template with a bank settlement leg. Restrict the predicate to business lines, mirroring the static catalog's settlement-leg exclusion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9ebb2e518f |
feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)
The old Bankavstämning report page was the only place a user could still tag a bank row as ingående balans or move it to another bank account, so the new /reconciliation page kept linking out to it and the reconciliation lived in two places. Both row tools now live on the account overview (hover-revealed, same endpoints), the ?autorun=1 deep link from the transactions inbox runs the matcher on the new page, and the report slug redirects: old links, ⌘K, the bokslut readiness wizard and the ignore toast all land on /reconciliation. BankReconciliationView and its FocusedReport branches (own range preset, help popover, autoRun plumbing) are removed; the source-grepping parity test for its quick-book path goes with it. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
704bf93e08 |
feat(categorize): confidence calibration engine + measurement loop (cascade step 4) (#1784)
Turns the selector's raw confidence into a score that means what it says. - lib/agent/categorize/calibration.ts: the engine. Isotonic regression (pool-adjacent-violators, distribution-free + monotonic) over (confidence, was_correct) samples → a calibrator; plus reliabilityByBucket, ECE, and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap. 12 engine tests (overconfidence pulled down, underconfidence lifted, monotonicity, ECE, band gating). - Measurement loop: migration categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]) + POST /api/agent/categorize/ outcome logging one sample (proposed vs actually booked) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped). AiCategorizeProposal surfaces the proposal metadata via onProposal. - scripts/fit-categorize-calibration.ts (read-only): prints the reliability diagram + ECE + fitted calibrator once data has accumulated. Fitting needs a few hundred real outcomes, so nothing calibrates today — the loop starts collecting, and "säker" stays uncalibrated (no auto-book) until the data proves it. 131 unit tests green; RLS covered by a pg-real test. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0531576807 |
feat(transactions): AI booking proposal in the quick-review dialog (cascade step 3) (#1782)
Wires the auto-booking cascade to the UI. When the quick-review dialog opens
for a transaction, AiCategorizeProposal fetches POST /api/agent/categorize
(Tier 1 deterministic candidates → Tier 2 model selector, provider-agnostic)
and:
- pre-fills the dialog's account + VAT from the model's pick (through
handleAccountChange, so class-2 VAT clearing is preserved),
- shows a confidence band (säker / trolig / välj konto), a short "Varför", and
the candidate alternatives (click one to re-apply),
- falls back SILENTLY to the deterministic defaults on error, and shows a soft
note when no AI backend is configured — the dialog always works without AI.
Surfaced inside the existing dialog (not a new card) so it reuses the proven,
balanced commit path. No silent auto-posting: "säker" is pre-filled and booked
with the dialog's existing one-tap Bokför; hands-off auto-book waits for
calibration (avoids any storno-on-undo).
lint + guards clean; 94 transaction/categorize tests green (no regressions).
Strings inline Swedish for now (lift to messages/{sv,en} pre-merge). UI PR:
awaiting founder visual sign-off before merge.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <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>
|
||
|
|
b77af371c4 |
fix(transactions): persist the source filter per company and stop the reset race (#1726)
The source filter was persisted under a browser-wide v1 key (#1105), but the stale-filter guard added in #1124 compared the restored value against sourceItems before cash accounts, skattekonto rows, and transactions had loaded, so every mount reset the in-memory filter back to 'Alla källor' while storage kept the old choice: restore-then-reset on every visit. - New pure helper components/transactions/source-filter-storage.ts: per-company v2 key, isSourceFilter moved out of the page, read/write helpers (read removes the retired v1 key once), and resolveEffectiveSourceFilter. - page.tsx keeps sourceFilter as the WANTED filter, restored per company (with a state-only ?source= URL override that is never written to storage); the guard effect is replaced by a derived effectiveSourceFilter memo used by every consumer, so a source that is still loading or went stale shows 'all' without destroying the choice. - ?highlight= deep links widen to 'all' in memory when the wanted filter would hide the highlighted row. - Unit tests for the helper; no i18n changes, no migrations. 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>
|
||
|
|
93541d7186 |
fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges (#1633)
* fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges Follow-up batch to #1629: the six documented deferred items from dev_docs/loading_states_analysis.md, in the same vocabulary (first-load-only takeovers, background reconcile behind mounted content, row/button-level pending, sequence guards). - Invoice detail pages: kundfaktura and leverantorsfaktura detail no longer blank the whole page for one-field changes. fetchInvoice shows the blocking spinner/skeleton only before the first paint (or when the pager steps to a different invoice); Bokfor / status / finalize / payment / send / Attestera / Markera betald / kreditera refetch behind the mounted page, the acting button shows a spinner-in-button, and the handlers await the refetch so pending covers until the content reflects the new state. The supplier detail's single isProcessing boolean became processingAction so the spinner lands on the clicked button only. (The leverantorsfakturor LIST try/catch/res.ok item was already fixed by #1629.) - useDestructiveConfirm: confirm(opts, action?) can now carry the destructive operation, so the dialog's existing isLoading spinner actually shows while it runs, dismissal is blocked meanwhile, and confirm resolves false if the action throws. Adopted at the /transactions row delete and the supplier- invoice detail delete (which previously permitted duplicate DELETEs with zero feedback). - Batch parallelization: new lib/concurrency.ts mapWithConcurrency (bounded worker pool, order-preserving, tested). /transactions batch categorize / ignore / delete run per-row requests 5 at a time instead of strictly sequentially; the bulkbar counter ticks per completed row. - Toast-spam reduction: batch categorize rows run silent (exit animation, count decrement and state patch stay; no per-row Bokford or generic failure toast) and ONE aggregate toast reports "N bokforda[, M misslyckades]" with a single Angra alla action that pools the same /uncategorize endpoint over every booked row (per-row undo is feasible today, so the aggregate is too). Interactive escalations (SI/CI match suggestions, duplicate warning, activate-account) deliberately keep their dialogs. - Underlag row-click flash: InvoiceInboxWorkspace handleSelect seeds the detail pane synchronously from the clicked list row and starts the document load in parallel with the detail GET (which hydrates on arrival), so a row click never flashes the onboarding/empty state, and a stale-response guard keeps a slow fetch from overwriting a newer selection. - #1629 round-2 edges: /pending holds the loading state when a fetch for a not-yet-loaded tab FAILS (never renders the previous tab's rows under the new tab's header, and never fakes an empty state); /transactions clears transactions/skvRows (+ count/paging) and bumps both fetch sequences on company switch, and loadSkvRows got the same sequence-guard pattern as fetchTransactions. Gates: full vitest suite green (14772 passed), tsc byte-identical to the origin/main baseline (stash-diffed), eslint 0 errors on touched files (warnings identical to baseline), check:guards green, package-lock untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): harden action feedback against stale responses and failures Address the seven CodeRabbit findings on #1633: - invoices/[id] + supplier-invoices/[id]: latest-request guard in fetchInvoice (sequence token) so a mutation refresh overlapping pager navigation can never commit invoice A's state under invoice B's URL; the deferred related-document writes are guarded too - supplier-invoices/[id]: try/catch/finally in approve/book/mark-paid/ credit/uncredit so a rejected fetch()/json() clears processingAction instead of leaving every invoice action disabled until reload - transactions: extend the skattekonto sequence guard to the connection-status write so a status response started under the previous company cannot flip the reconnect banner for the new one - transactions: runCategorize resolves { ok, journalEntryId } so the batch aggregate counts a 200-with-null-journal-entry booking (flag flip) as success instead of narrating it as misslyckades; Angra alla only targets rows with an actual verifikat, since the storno endpoint rejects rows without one - transactions: shared undoneIdsRef lets "Angra alla" cancel a pending finishBooking state patch; a fresh booking clears its row's entry so re-booked rows still get their delayed patch - InvoiceInboxWorkspace: monotonic request tokens for the detail and document reads so a same-item reload cannot resolve out of order and paint a stale snapshot or document URL - messages: ICU plural for the success part of both partial batch descriptions in sv and en (1 bokford, not 1 bokforda) 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> |
||
|
|
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> |
||
|
|
c897a906df |
fix(ux): actions update lists in place - no more takeovers, jumps and dead air (#1629)
* fix(ux): update lists in place on actions instead of takeover spinners and jumps Founder report: the app feels glitchy when clicking around, especially when deleting a row or booking something. The repo-wide anti-pattern behind it: single-row actions trigger whole-list skeleton/spinner takeovers (layout collapse, scroll jump, full stagger-enter replay), deletes give zero feedback then hard-jump, and the /transactions exit "animation" was filter-only and never animated. Per surface: - Never take over a rendered list for a background refresh. The skeleton/spinner swap is now reserved for an empty (or foreign) list on /transactions (fetchTransactions), /pending (fetchOperations, covering both listed Granskning findings, one file), kundfakturor (fetchInvoices), leverantörsfakturor (fetchInvoices, plus try/catch/finally so a failed fetch can no longer stick the skeleton or masquerade as an empty register) and the verifikat list (JournalEntryList now takes a refreshToken prop and refetches in place; /bookkeeping no longer key-remounts it into a spinner, so expansion/selection/pagination/scroll survive a created verifikat). Quiet inline Loader2 cues near the list headers on /transactions and /pending signal a background reconcile. - /transactions row exit: exiting rows (booked/ignored/deleted) stay rendered through the existing 350ms window with a real exit transition (.row-exit: fast fade, then the space closes by transitioning cell paddings/line metrics and a numeric max-height on the fixed-height cell spans) and pointer-events off. Instant removal under prefers-reduced-motion. Applied to the inbox cards, the skattekonto card and the history rows. - /transactions delete: routes through processingId (row spinner) and the exitingIds path, and decrements totalUncategorizedCount when the deleted row was pending (the realtime echo is not guaranteed for DELETE on a filtered subscription). - FyPicker double-fetch: the initial fetch now waits for FyPicker's onReady (fires after its persisted-scope restore), so mount does one correctly scoped fetch instead of racing an unscoped fetch against the restore refetch (list -> skeleton -> list on every visit). Period changes refetch background-only behind the client-filtered list. - Pagination survives realtime echoes: background refreshes re-fetch range(0, pagedCountRef) instead of resetting to the first 200 rows, so "Visa fler" pages no longer collapse after any action. Gates: full vitest suite green (14764 passed), tsc output byte-identical to the origin/main baseline, eslint 0 errors on touched files, check:guards green, package-lock untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): apply review round on action-feedback smoothness - /pending: sequence-guard fetchOperations so a stale previous-tab response can't overwrite the current tab's rows, counts, or loading cues - /pending: check res.ok on the pending fetch and both history fetches before applying payloads; failures keep current rows and surface the existing error toast - /transactions: reset fiscal scope (fyReady/fyPeriodId/fyPeriod) during render on company switch so FyPicker re-runs its persisted restore and stale bounds never scope a fetch for the wrong company - /transactions: drop a deleted row's id from selectedIds so the bulk bar can't act on a deleted row - row exit: add the inert attribute on exiting row wrappers alongside pointer-events so keyboard focus and activation are blocked too - JournalEntryList: preserve selection on refreshToken background refreshes (reconciled against the refreshed page); user-initiated reloads still clear it 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> |
||
|
|
4dbd19aeb0 |
fix(transactions): the underlag column is one surface, not a dropzone and an orphan button (#1628)
* fix(transactions): the underlag column is one surface, not a dropzone and an orphan button
The Bokfor transaktion dialog stretched its empty dropzone into a
45/72vh well and pinned "Valj befintligt underlag" alone at the very
bottom of the column, visually disconnected from the dropzone it
belongs to.
- Empty state: the underlag column now sizes to its content and
top-aligns; the inbox picker renders as a quiet full-width dashed
row directly under the dropzone ("eller valj befintligt underlag
fran inkorgen"), so drop-a-file and pick-from-inbox read as one
intake surface. The fixed-height sticky column returns as soon as
a document previews there (uploaded, picked, or pre-linked).
- Grid rebalanced from 1fr/520px to 2fr/3fr so the kontering side
dominates while nothing is being previewed on the left.
- QuickReviewDialog gets the same footer-row treatment for its picker
trigger inside the underlag collapsible, so both #1620 surfaces
present the affordance identically (disabled-while-booking kept).
Presentation only: upload path, select-mode picker held until booking,
linkDocuments with inbox_item_id, picked-state resets on close and
transaction change, and duplicate-match wiring are all unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the QuickReviewDialog picker-trigger consistency decision
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>
|
||
|
|
3036c3d879 |
feat(transactions): pick underlag from the inkorg in quick review (#1620)
* feat(transactions): pick underlag from the inkorg in quick review The quick-review dialog reached from "Att göra -> Bokföra transaktioner" only offered a local-file dropzone, so a receipt already sitting in the underlagsinkorg had to be re-uploaded. That also left the inbox item unconsumed, leaving a duplicate in "Underlag att hantera". Wire InboxDocumentPicker (select mode) into the Underlag section, next to the upload zone, mirroring TransactionBookingDialog. Picked documents are held until the booking returns a verifikat, then linked through the existing linkDocuments call with their inbox_item_id, which stamps the inbox item as consumed. Picks reset on close and when the dialog moves to another transaction, so a document picked for one row can never attach to another row's verifikat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): clear inbox picks when the underlag link fails The partial-failure branch returned before the cleanup, so a pick that had already linked (and been consumed) stayed in state and would be re-linked on the next transaction the reused dialog opened. Picks are safe to drop: one that failed to link was never stamped, so it is still in the inkorg. Also record in DECISIONS.md that no "saknar underlag" predicate keys on transactions.document_id, which is what made leaving the pin off safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): lock the inbox pick controls while booking handleConfirm captures pickedInboxDocs when it starts, so a document picked after the booking request went out was never linked, and the cleanup on completion then removed it from the list with no error. The pick and remove controls now follow the rest of the dialog and disable while isProcessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9686b54b41 |
refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for which went where; one toolbar row on /transactions mixed four shape languages. This locks a 4-tier ladder (design.md convention 16): - pill: interactive toolbar controls (buttons, chips, pickers, segmented controls, toolbar search, count nubs) - rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs - rounded-lg (8px): cards, form fields, popover/menu content, boxes - rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs) Changes: - New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the hand-rolled bg-muted/70 tablist copied across 11 files - New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars; dialog/picker searches keep the rounded-lg Input - dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette - ContextPicker chips at the shared h-8 toolbar height - ~300 rounded-md / bare rounded call sites remapped by role; auth icon tiles and the mobile nav sheet come down from 16px to 12px - rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead vocabulary, enforced by a new off-ladder-radius check in check:guards Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc clean on all changed files, sandbox screenshots of transactions/ bookkeeping/granskning toolbars and the Ny verifikation dialog. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
57d6651cfc |
feat(empty-states): startkort on six pages with strata imagery (#1603)
Replace the true-empty states on Kundfakturor, Transaktioner, Underlag, Loner, Bokforing and Skattekonto with StartCard: a self-contained dark hero (image-derived ground baked into the strata render, white primary CTA) that says what the page can do instead of what is missing. Primary CTAs lead with the connect/setup action per page (bank via PSD2 deep link, mailboxes, Skatteverket, migration import); filtered/search empty states and viewer fallbacks keep the old compact states. Design signed off in the Startkort prototype iterations 2026-08-13. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
08440fed94 |
feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat A first-class Fortnox/SIE migrator path: after SIE import plus bank connect or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or suggestion-matched (0.75-0.89, persisted for review) against the imported verifikat, with a guided review surface, instead of landing as anonymous "Att bokfora" rows. Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account pooling); widen payment_match_log action CHECK with linked_to_existing_voucher (silently unlogged since March). Phase 1: potential_journal_entry_id/method/confidence on transactions with CHECK + invalidation triggers; persistSuggestions in runReconciliation; sweep after bank CSV import with SIE overlap (suppressing auto-categorization); sweep summaries stamped on bank_connections and bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with per-pair server-side revalidation (voucher consumption + bank-leg amount and direction). Phase 2: "Granska forslag" review tab on Transactions with chunked bulk confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode, mutually exclusive with dry_run), attn line, pre-migration row marker. Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant of the account-picker #917 nudge, sweep outcome on the onboarding checklist bank step. Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9 and persist the review band instead of auto-committing fuzzy matches. Migrations already applied to staging under the same versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): resolve PR review findings in one pass Swedish accounting review (both previously-deferred holes closed): - runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus event alone lands in the 30-day event_log and is not an audit record. - The three match-route storno-conflict branches detach reconciliation links via unlinkReconciliation instead of storno-reversing the linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). - Historical gap quantified on prod (read-only, recorded in DECISIONS): 762 unlogged manual links across 52 companies since 2026-03-23. CodeRabbit: - confirm-suggestions route: maxDuration 300 for full 500-item batches. - AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the async gap-fill probe cannot override an explicit choice. - enable-banking post-backfill sweep: persistSuggestions so the review band is not dropped. - bank-file execute: sie_sweep stamp errors are logged, not swallowed. - ImportResultStep: sandbox keeps the CSV CTA (file import works there). - payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan under ACCESS EXCLUSIVE. - logMatchEvent calls awaited (serverless can freeze unawaited work). - DECISIONS.md stale version reference annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): defer reconciliation-link detach until the match commits Round-2 review findings: - CodeRabbit: the eager unlinkReconciliation call could orphan a transaction if the match flow failed after it. All three match routes now persist NOTHING up front: the final transaction update overwrites journal_entry_id and clears reconciliation_method in the same write, so any failure in between leaves the existing link intact. The release is logged as 'unmatched' after the commit. - Swedish review: the auto_suggested logMatchEvent in runReconciliation is now awaited like every other audit write. - DECISIONS entry split into compliance/CodeRabbit lines and updated to describe the deferred detach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner The conditional spreads introduced with the deferred detach pushed the scanner's unresolvable-expression count past its ceiling (380 > 378). reconciliation_method: null is correct unconditionally on a confirmed invoice/supplier match (null is already the value on every row that was not reconciliation-linked), so the payloads become plain literals the guard can verify. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0d3ba5268d |
fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3 The booking-time duplicate guard missed the most common bank-fee twin shapes: - G1: the sibling scan matched on the EXACT date only, so a duplicate import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was invisible. The scan now uses a +-3 day window with a deterministic ranking where exact-date candidates always outrank drifted ones (force=true re-detection stays bound to the reviewed candidate). - G2: booked-ness required transactions.journal_entry_id, so bulk-booked (transaction_voucher_links) and multi-allocated (invoice_payments / supplier_invoice_payments) siblings read as unbooked. The scan now batch-fetches the anchor rows and resolves the verifikat via getPrimaryJournalEntryId (is_transaction_booked semantics). - G3: the ledger scan excluded every voucher linked to any transaction, so a voucher booked from a date-drifted duplicate row escaped BOTH halves and the booking proceeded with no warning. A voucher whose linking transaction itself matches the target (same ore in the same currency, compatible cash account, date in the window) is now returned as the twin with transaction_id set. All candidate picks keep explicit total-order tiebreakers so a force re-detect returns the same candidate the user reviewed, and the SEK-or-null amount contract is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog The duplicate dialog hid its match action for sibling-transaction candidates (canMatch required transaction_id === null), so the user who most needed steering saw only 'Bokfor anda'. manualLink explicitly allows N:1 links, so the match action is now offered for both candidate kinds. Sibling candidates get question-form body copy ('vill du matcha mot verifikatet i stallet?') and an additional 'Ignorera transaktionen' action via the existing POST /api/transactions/[id]/ignore, which is the correct resolution when the row itself is a duplicate import (matching would double-count the bank side, booking the ledger side). Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a destructive toast with no way forward: - the counterparty-template branch of handleQuickReviewConfirm now sets the shared duplicateWarning state exactly like runCategorize, with the force retry bound to the reviewed candidate's voucher - BankReconciliationView's quick-book now opens the same dialog, with match/ignore refreshing the reconciliation lists New sv/en strings: dialog_duplicate_body_sibling, dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level parity tests pin the 409 routing and the dialog affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): duplicate guard on the bulk-book samlingsverifikation path /api/transactions/bulk-book never called detectBookingDuplicate, so a batch containing an already-booked twin minted a second verifikat with no warning. The route now runs the shared per-tx guard before the RPC, with intra-batch exclusions (the other selected txs are distinct events the user picked, and the link-existing target voucher is the batch's own destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the candidate and the flagged tx id. BulkBookDialog routes the 409 into DuplicateBookingDialog for review (view voucher / cancel / book anyway) instead of a dead-end toast; 'Bokfor anda' re-runs the batch with force=true. On force the route re-detects and records each dismissed candidate as BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2 kap 8), parity with the /categorize bypass. Detection failures stay fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions) bypasses this route and remains unguarded; guarding inside the RPC needs a migration and is out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): gate the duplicate-dialog ignore hint on the action being present The sibling body copy mentioned ignoring the row, but two render sites (the manual booking form and the bulk dialog) show sibling candidates without the ignore action. The guidance now lives in a separate dialog_duplicate_ignore_hint string rendered only when the Ignorera button itself renders, so copy never points at a button that is not there. 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> |
||
|
|
1eebb75269 |
feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.
New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.
UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.
New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e51a2c8102 |
refactor(design): no amber boxes, attention is one ochre sentence (#1562)
The founder wants the yellow boxes gone everywhere. The design system already agreed: status colors are data, not chrome (convention 12) and attention is a single ochre sentence, never a banner (convention 6). This enforces it: - ConfirmationDialog: the amber warning panel is now an AttnLine, and the hardcoded Swedish default warningText is gone: it injected an immutability warning into dialogs whose authors never asked for one (every current caller passes the prop explicitly, so no behavior change at any call site). - Badge warning variant: amber fill replaced with a hairline chip and ochre text. - DestructiveConfirmDialog warning variant: neutral icon disc, default primary confirm button (only --destructive survives as chrome). - BankSyncStatusChip stale state: same neutral shape as the healthy chip, ochre text carries the signal. - SandboxBanner: solid amber bar becomes secondary-on-border chrome. - BankIdAuth, BankIdCompanyPicker, SessionTimeoutModal: the last three raw-amber (bg-amber-*) holdouts moved onto tokens, the company-picker banner becoming a plain AttnLine. - Mechanical sweep of the ~58 hand-rolled bg-warning/border-warning boxes across 45 files: fills to bg-muted/30 (icon discs bg-muted), borders to border-border, text-warning-foreground to text-attn. The account-class dots in account-number.tsx keep bg-warning: they are data indicators, not chrome. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
abff8d90f6 |
refactor(ui): validation as errors on attempt, help text behind the ? (#1561)
The founder flagged the app as bloated with standing instructional text. Ny verifikation: the what's-missing lines (Ange en beskrivning, Minst två rader...) rendered from the first frame because their only gate was form validity, which an empty form fails: instructions dressed as validation. The submit buttons now stay enabled and an attempt on an incomplete form is what surfaces the lines, in destructive red, per the error-on-submit idiom. The Enter-to-advance flow keeps the old completeness predicate so navigation is untouched. Matcha mot befintlig verifikation: the two-sentence explainer moved behind a ? (HelpPopover, convention 7), the N:1 note tightened, the Visa även matchade switch became a quiet link (switches are settings idiom), and Stark träff, the normal auto-selected case, renders as muted text instead of a chip (chips mark exceptions, convention 5). Deleted always-visible paraphrase lines and their orphaned keys: items_card_description, picker_description, references_subtitle, sort_stack_hint, dimensions hints, document_help, and the dead fill_balance_hint key that had no render site at all. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1e9f245f7c |
refactor(agent): keep the assistant in the nav, FAB and underlag flow only (#1557)
The founder wants the scattered per-page assistant buttons gone: the assistant is reachable from the nav and the floating tab everywhere, so in-page duplicates were noise. Removed the AgentSparkleButton call sites (year-end, verifikat detail, supplier invoice detail, invoice editor) and the now-orphaned component, the soft hand-off link in the Ny verifikat modal (plus its i18n keys), and the transaction-row overflow item. Kept: nav entry, floating tab, the Dokumentinkorg flow, and the sanctioned "Skapa med assistent" split-button mode on /bookkeeping (design.md convention 14). The command palette's hand-off entries hardcoded the agent name "Anna"; they now use the identity from AgentSheetProvider like every other affordance, and hide until agent onboarding is done (same gate as the FAB). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dea5e31756 |
feat(skattekonto): bulk Bokför + inline single-row booking (fewer clicks) (#1535)
* feat(skattekonto): bulk Bokför and inline single-row booking
Booking a year of skattekonto events took 6 clicks per row (list, Bokför,
review page, Bokför, confirm, navigate back), even for a +1 kr
intäktsränta row.
- GET /skattekonto/transaktioner now attaches a deterministic
booking_suggestion per unbooked row (one hoisted skattekonto_rules +
entity_type fetch via the new attachBookingSuggestions), shown as muted
text on the inbox row ('Bokförs mot 8314 ...').
- New POST /skattekonto/transaktioner/bokfor-batch (Zod, max 200 ids):
sequential draft+commit per row server-side (no orphan drafts), per-row
results, never aborts on a row failure. Commit attribution: bulk_accept
for real batches, user_accept for the one-row inline flow. A failed
commit keeps the linked draft (degrades to the old review flow).
- Inbox: hover-reveal checkboxes on eligible SKV rows (suggestion
present, no duplicate hint, unbooked, genomförd), separate skvSelectedIds
set, bulkbar 'Bokför valda (N)' with ONE summary ConfirmationDialog
grouped by suggestion with per-group sums, chunked batchProgress, ONE
aggregate toast, local state patch with exit animation.
- Single-row: new SkattekontoBookDialog (dynamic import) replaces the
draft-then-window.location detour on both /transactions and /skattekonto;
'Öppna som utkast' keeps the old draft path via router.push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skattekonto): batch booking guards for unsettled rows, double-post race and locked periods
- reject status != booked rows in the batch flow with NOT_SETTLED before
any draft exists (server no longer trusts client eligibility); the
single-row draft endpoint keeps its behaviour
- make the journal_entry_id backlink a conditional claim (update where
journal_entry_id is null, select affected rows): zero affected rows maps
to ALREADY_BOOKED and commitEntry only runs after a won claim, so two
concurrent submissions can no longer double-post the same row
- detect the period-lock trigger signature in the batch catch and map it
to PERIOD_LOCKED with Swedish text instead of UNKNOWN with raw DB output
- SkattekontoBookDialog: rows with no matched rule no longer get the
guaranteed-422 draft CTA; they route to the existing match flow and to
manual verifikat creation in /bookkeeping
- pass an explicit skv_book_dialog.commit_warning key for the
direct-commit warning instead of ConfirmationDialog's hardcoded default
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>
|
||
|
|
9dbaebcc50 |
fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)
* feat(invoice-inbox): store HTML mails as underlag, expandable field editor Body-only mails and .html attachments (including forwarded .eml bodies) no longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a self-contained text/html document, stored through the normal upload/extract pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail itself can serve as bookable underlag. Empty mails keep the error row, unsupported types are still rejected, and webhook retries dedupe on resend_email_id. Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe sandbox in the workspace preview and a CSP sandbox header on /api/documents/:id/inline for text/html. The type is accepted only from the email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload. The "Extraherade falt" rail gains an expand button opening a centered dialog with the same autosaving field editor at a readable size (two columns), which also gives every failed or skipped extraction a manual fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): sortable verifikat list headers with amount sort - clickable sort toggles on the verifikat list headers (asc -> desc -> default) - total_amount computed column + sort_by total/description on the list route - failed list loads render an error card with retry, never the empty-ledger state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): decode bank CSV as Windows-1252 fallback in column mapping The client read the uploaded file with file.text(), which is UTF-8-only, so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with U+FFFD in place of Swedish characters. Decode from bytes with the shared decodeFileContent() helper, matching what the server parse route does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): stackable sort keys on verifikat list headers - shift-click adds a column as secondary/tertiary sort key (max 3), plain click keeps the single-key tri-state cycle - sort_by accepts a comma-separated priority list; single tokens stay valid - voucher tiebreak follows the last key's direction (#972 parity) - priority numbers on stacked headers; hint text in the filter dialog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep ROT/RUT deduction fields positive on credit notes Crediting an invoice with a ROT/RUT deduction failed 100% of the time: the credit-note path negated deduction_total (and per-item deduction_amount) like the other amounts, but both columns carry CHECK (>= 0), so Postgres rejected the insert and the user only saw 'Kunde inte skapa kreditfaktura'. Store the deduction fields as positive magnitudes, matching the convention everywhere else. The stored sign is inert on credit notes: the reversing verifikat recomputes the ROT/RUT split from the items, and the PDF and amount-to-pay logic skip deductions on credit notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(transactions): share the source chip across inbox and history modes Move SourceFilter to transaction-types.ts (widened with 'bank:other' and 'acct:<id>'), render the one toolbar ContextPicker in both view modes, and drop the narrower duplicate chip inside TransactionHistoryList. The history list now applies the acct:/bank:other narrowing itself and hides skattekonto rows under any bank-side selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 4.3.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): recognize PostgREST computed columns in the migration parser The verifikat amount sort orders by total_amount, a PostgREST computed column (a function on the journal_entries row type, migration 20260811100000). The schema guard only modeled real columns, so no-phantom-columns flagged the order as a phantom. Teach the parser that a function whose only argument is a table's row type joins that table's column set, with DROP FUNCTION retraction when the signature names the row type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve PR #1523 review findings - journal-entries route: append the globally unique id tiebreak to every direct-query sort; voucher series+number repeat across fiscal years, so the all-years scope could duplicate or drop rows at page boundaries. Existing order assertions updated, new all-years tiebreak test. - documents inline route: CSP source policy on HTML previews; sandbox alone still loads remote resources, letting a tracking pixel notify the sender on open. New route test asserts the full header. - JournalEntryList: catch rejected list requests so loading cannot stick forever, and gate every post-await state write behind a request generation so a slow earlier request cannot overwrite the current sort. - TransactionHistoryList: pagination follows the selected source scope (reachable with zero matches on the current page, hidden for the skattekonto scope it cannot affect). - transactions page: bank:other picker availability derives from history rows too, not only the pending inbox dataset. - DECISIONS.md: mark the superseded single-sort decision; record the credit-note deduction positive-magnitude invariant and its verified reader inventory (Swedish review flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard metadata refetches behind the list request generation fetchAttachmentCounts and fetchRattelseFlags write state after their own awaits; a stale list request's late completion could overwrite attachment counts and rattelse flags for rows a newer request just rendered, showing false missing-underlag warnings. Both helpers now take the caller's generation guard and discard stale completions, including the attachment-counts loaded flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ce1e0b7642 |
feat(banking): the bank-connect payoff (#1477)
* feat(banking): the bank-connect payoff: point the first sync onward Fifth activation slice. Every successful connect passes through the sync-progress dialog's done state, which used to end on a bare Klar that stranded the user on the settings panel. Now it is the payoff: the imported count stays, the work now waiting gets named (N att bokföra · M matchar fakturor, from the same worklist counts endpoint the dashboard pane refetches), and the primary action becomes Visa N att bokföra -> /transactions, where realtime rows, the match pills and the Att bokföra badge already deliver the rest. Zero-import syncs keep the plain Klar. Also mounts the fully-built-but-orphaned BankSyncSinceLastVisit pill on the transactions footer, so returning users get the same payoff line for the nightly cron (N nya transaktioner sedan sist). Strings stay hardcoded Swedish inside the extension, matching every neighboring string in the dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(banking): review triage: no stale counts, no marker advance on error 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> |
||
|
|
70845edf69 |
feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name
Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:
- transactions.transaction_method (text + CHECK closed vocabulary: card,
transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
bank_transaction_code / proprietary_bank_transaction_code evidence
columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
explicit source method (Stripe txn.type) > trailing Swedish channel
phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
as original_description; dedup is untouched (external_id is date+öre,
the content bridge reads original_description and is prefix-based, and
a trailing strip leaves a prefix). Enable Banking passes the codes
through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
(+ MCC and Stripe prefixes) and strips unedited titles; user-edited
titles are never rewritten.
- mapping-engine also matches original_description so user rules written
against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
"Betalsätt: Kortköp" etc. (sv/en), making every classified row
expandable.
A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): anchor counterparty-template identity on original_description
Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.
Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.
Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): review follow-ups on method classification
- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
refinement on the proprietary code beats a bare family match on the
ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
that only match original_description, including the invalid-regex
substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
mapping to 'fee'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): scope method classification to feed rows + adjective guard
Multi-bank risk hardening before the backfill ships:
- Feed-row scope: classification and title stripping now require a real
import feed (import_source present, not manual/mcp), both at the
ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
every backfill statement. User-authored titles like "Egen insättning"
on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
is skipped, so "Egen insättning" stays whole even on bank-feed rows;
the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
phrase means no method and no rewrite, so the worst case for any bank
whose vocabulary we have not seen is the status quo.
Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations after rebase
Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): bump transaction_method versions past prod's max
Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker
- TransactionInboxCard: row-level Enter/Space handling now ignores events
bubbling from nested controls, so keyboard activation of Bokför / the
overflow menu is no longer cancelled by the (now much more common)
expandable row.
- Feed predicate parity with isImportedTransaction(): a live
bank_connection_id marks a feed row even when import_source is unset
(the oldest PSD2 rows predate that column), in both the ingest
classifier and every backfill statement: those legacy rows now get
classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail
Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): enforce, not assume, original_description preservation in the title strip
The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the compliance-review triage of the backfill's booked-row title strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
|
||
|
|
858ad49852 |
fix(booking): explicit Ingen moms in quick review books exempt, not the derived default (#1450)
QuickReviewDialog collapsed 'none' to undefined on the wire, so the server re-derived the category default and booked 25% moms against an explicit no-VAT choice while the preview showed no VAT line. 'none' now maps to 'exempt' only when it deviates from the seeded default: untouched bookings on exempt-default categories (bank fees) stay byte-identical, an explicit Ingen moms books no VAT line with the classification the momsdeklaration should see, and the class-2 account auto-'none' stops booking a stray moms line. Same gap the swedish-compliance bot flagged on #1443. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c0a106e591 |
feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the initial VAT treatment, overriding the server's per-category derivation and claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit 'Enligt kategori' option that omits vat_treatment so the server derives it (exempt bank/card fees, 12% representation). Reverse charge is never derived. The embedded JournalEntryForm period Select is replaced by the same derived read-only text the standalone variant already uses: the period is a total function of the entry date, and the Select allowed picking a period that disagreed with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt BookDirectlyDialog and the supplier-invoice form left the cost account deliberately blank even when the company's own confirmed history for the counterparty (categorization_templates) or supplier.default_expense_account knew the answer. Both now prefill from a counterparty-template hit (new ?counterparty= single-match mode on the settings route, same tiered matcher as the booking flows), only into still-empty fields, only from expense-shaped templates, with a provenance line. No generic fallback: a miss leaves the field blank exactly as before. Bokfor direkt's period Select is replaced by text derived from the entry date; the silent periods[0] fallback becomes a blocking explanation, since borrowing an arbitrary period could book into the wrong one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ux): single-company login skips the picker; filing surfaces default to filable periods /select-company auto-forwards when the user is a member of exactly one company with nothing else to decide (no new TIC engagements, no pending invite, enrichment fresh); the in-app 'Lagg till foretag' links pass ?choose=1 to keep the picker deliberately reachable. Byra/multi-company users are untouched. The VAT declaration now opens on the most recently ENDED month/quarter (lib/vat/period-defaults, tested) instead of the current one, which can never be filed and forced a step-back click on every filing visit; the periodicity switch resets the same way. Helarsmoms FyPicker gains preferLatestEnded and opens on the latest ended rakenskapsar instead of the newest started one. The 'momsperiod saknas' dead end now collects the answer inline through the same PUT /api/settings validation instead of bouncing to settings: until the period exists the deadline engine generates zero VAT deadlines, silently, so every extra hop kept a compliance hole open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(granskning): approve pill commits directly for low and medium risk The Godkann pill on /pending only opened a ConfirmationDialog demanding a second Godkann, regardless of tier. The review row already states source, title and risk and offers Detaljer, so for low/medium the pill now commits directly; high risk keeps the dialog, whose warning sentence carries information the row does not. Chat-side bulk approve is deferred: it needs ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reports): map inline momsperiod save errors through getErrorMessage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): repair the dead login auto-forward and nine review findings The big one: setActiveCompany ends with a cookie write that throws during Server Component render (sealed cookie store), so the /select-company auto-forward silently never fired; the write is now best-effort since the cookie is write-only compat and the DB write is already verified. Also: supplier-switch un-plants history-prefilled accounts so the new supplier's own default applies; prefill routes through handleAccountChange so konto default moms rides along; batch 'Ingen moms' books exempt instead of the derived 25%; monthly VAT default tracks the actual 12th/17th filing deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState, gates on vat_number (the PUT would 400 without it), keeps keyboard focus and announces errors; cost-account shape guard tightened to P&L accounts; attn tone on the new warning lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger workflows; the Actions outage swallowed the rebase push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after outage (events dropped, not delayed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after GitHub Actions recovery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address CodeRabbit and compliance-bot findings Direct commit now prunes the op from the bulk selection (a stale id kept inflating the bulk bar and rode into bulk-commit) and the detail-panel Godkann gets the same risk gate as the row pill. The automatic account fill in the supplier-invoice form is requested, not applied inline: the applying effect waits for both the BAS chart and the request with fresh closures, so a fill can no longer land before the chart and leave a VAT-free konto on the 25% row default. Test dates use local-time constructors (ISO strings parse as UTC midnight and shift a day in negative-offset timezones). Stale ML 11 kap citation dropped from a comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger; push event dropped again 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> |
||
|
|
392e847c1e |
fix(transactions): block invalid invoice match targets (#1294)
Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage. Fixes #1260 |
||
|
|
954ce873a8 |
fix(transactions): confirm and allow undo on a counterparty booking (#1292)
Booking via "Tidigare motparter" ended on a bare setExitingIds().add(id): the row animated out of the inbox and that was the entire feedback. No "Bokförd" toast, no Ångra, no unbooked-count decrement, and the id was never removed from exitingIds again, so an undo would have restored the row's data while leaving it filtered out of the list. Extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch: the counterparty path was already a second, thinner implementation of it, which is why it drifted. Both paths and the counterparty activate-and-retry go through it now, pinned by a parity test. Also fixed on the way: - handleTransactionBooked (manual booking dialog / voucher match) never decremented the unbooked count, so the header read one too high until the next refetch. It deliberately gets no Ångra: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at. - The 350ms animation timer re-applied the booked shape unconditionally, so an Ångra resolving inside that window left the client claiming a journal_entry_id the server had already storno-reversed. A completed undo now wins. - finishBooking cleared processingId unconditionally; scoped to the finished id so it cannot wipe an unrelated row's spinner. No migrations. |
||
|
|
198d3092c7 |
fix: counterparty template pick crashes the page (#1291)
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.
Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).
Five more page-crashes of the same shape, adversarially verified:
- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
a toast description. The Toaster is a sibling of {children} in the ROOT
layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
workspace was in the error boundary for every company that had opened it.
Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.
Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.
No migrations.
|
||
|
|
108f348c84 |
refactor(motion): retime content entry, stop the double and one-frame animations (#1282)
The stagger is the standard content entry on every migrated page (convention 11), so its budget matters more than any single surface. It ran 500ms per item in 80ms steps, putting the 10th row at 1220ms against a 300ms UI budget, and it only defined delays for children 1-10: on any list longer than ten rows, children 11+ inherited delay 0 and arrived BEFORE the middle of the list. Now 300ms per item in 40ms steps with the delay capped at 360ms, so the tail lands at ~660ms instead of ~1220ms. The cap is on the delay, never on the animation: the `both` fill holds a child at opacity 0 until its delay elapses, so excluding rows 11+ from the animation would paint them while rows 1-10 were still invisible. Adds --ease-emphasized (the strong ease-out) rather than retiming --ease-out, which is unlayered in :root and therefore shadows Tailwind's own token: changing it would retime every ease-out utility in the app. Foldout rows in JournalEntryList, periodiseringar and TransactionInboxCard sit directly inside a staggered tbody, so expanding a verifikat fired the inherited slideUp (with an invisible pre-roll of up to 320ms) on top of the foldout's own transition. They opt out via data-no-stagger. Also: the confirmed match on Hem faded at full height and then vanished in one frame, jumping everything below it 52px; it now grid-collapses over 200ms with the gap inside the collapsing area. The assistant sheet slid nothing while the page panel animated 300ms to make room for it; it now arrives along the same edge on the same curve, gated to first mount so re-expanding a collapsed session stays instant. Chat history no longer replays 20 simultaneous 500ms page-entry slides on resume: only genuinely new messages animate. Payroll wizard segments transition their colour. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d02a74147 |
refactor(ui): system hygiene sweep from the UI craft audit (#1281)
Raw palette: SalaryCalendar was the only file in the salary cluster still on bg-red-100 / text-amber-800 style classes, with zero dark: variants, so every absence pill rendered paper-white on a near-black page. All 30 are now alpha-over-token on the semantic scale, which needs no dark: variant (the approach badge.tsx already takes). Eight absence types share four tones, so fill carries a second axis: filled vs outlined separates the types whose Lucide icon is identical (Heart is parental, pregnancy and care_relative; Activity is study and other_leave). Primitives: - select and dropdown-menu now scale from their trigger via the Radix transform-origin variables instead of from their own middle. Dialog stays centred: modals are not anchored to a trigger. - toast used fade-out-80, the one animate-* class with no @utility in globals.css, so --tw-exit-opacity fell back to 1 and the toast slid away at full opacity. Enter and exit now also share one path per breakpoint (top on mobile where the viewport is a full-width bar, right on desktop where it is a corner card) using max-sm:/sm: rather than stacking both, which would have produced a diagonal. - slide-over exited on ease-in, which delays the moment the user has already decided to leave; it enters on the drawer curve and now leaves on it too. - progress animated transition-all where only transform changes. - One app-wide TooltipProvider in the root layout. skipDelayDuration is provider-scoped, so a provider per instance meant the grace window could never fire and every account number in a huvudbok re-paid the full 200ms. Also: nine hand-rolled animate-pulse placeholders in three different greys to the Skeleton primitive, 25 CardTitle text-lg to the locked text-base, two always-on chips to muted text (a chip on every row is not an exception), the three extensions titles onto the display face, the help filter chips onto pill geometry, and the dead border-border/60 on the /import row. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |