Commit Graph
665 Commits
Author SHA1 Message Date
MattssonandClaude Fable 5 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>
2026-08-13 23:12:27 +02:00
Mattsson 07e89d9b52 feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation

* fix(invoices): harden Peppol compliance guards

* fix(api): narrow Peppol document loading

* test(pg): hash Peppol fixture payload

* fix(invoices): address Peppol review findings

* test(pg): isolate Peppol provider events

* test(pg): isolate Peppol submission fixtures
2026-08-13 19:44:32 +02:00
MattssonandClaude Fable 5 05380ddf54 feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

Correcting or reversing an entry that already sits 3+ links deep in a
rattelse chain (correction_of_id/reverses_id walked in the DB, never
description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the
caller to book ONE correction expressing the chain's net effect. Agents
looped storno+rattelse 10 deep on a live company (63/193 vouchers noise).

The guard is advisory, never a dead end: allow_deep_chain bypasses it on
every surface (correctEntry/reverseEntry option, REST body, MCP tool arg
staged through pending_operations, and confirm dialogs with Ratta anda /
Aterfor anda in the web UI). MCP staging pre-flight fires the guard at
stage time so the agent reconsiders in the same turn, and the executor
re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for
the two bypass properties (trimmed to one sentence first).

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

* feat(agent): retry the Bedrock stream once on transient failures

A transient stream death (429/5xx, transport cut, or the two known
stream-corruption signatures: 'Unexpected event order' and 'request ended
without sending any chunks') killed the whole chat turn, stranding the
user mid-answer. The turn now retries once per turn after a short backoff:
safe because nothing is persisted until finalMessage() succeeds. A new
stream_restart event carries the pre-attempt text snapshot so the chat
client resets the partial bubble, drops uncompleted tool chips, and shows
'Forsoker igen...' until the retried stream produces text. Non-transient
errors (403, 400) keep the existing immediate-error path.

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

* fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1

apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain,
making references/journal-entries.md stale. Regenerated (hand-applied: the
generator output is deterministic from the registry). While wiring: the v1
correct route validated allow_deep_chain but dropped it, and the v1 reverse
route's strict body schema would have rejected it outright, leaving API
clients no bypass when the chain-depth guard fires. Both now forward the
flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall.

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

* chore: re-trigger CI after Vercel infra hang

The preview for e527e4044 compiled in 91s then hung 40 minutes in the
TypeScript phase and was killed with no error output; a CLI redeploy of
the identical code went Ready in 5m. Empty commit to refresh the git-
triggered deployment status.

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

* fix(bookkeeping): address CodeRabbit review on the chain-depth guard

- correction-chain: report rootVoucher only when the walk reached a
  genuine parentless root; a broken link, cycle, or hop-cap now yields
  null instead of presenting an intermediate voucher as the chain root.
- recordate: propagate allow_deep_chain end-to-end (recordateEntry
  option, route schema, and a Flytta anda bypass confirm in the dialog);
  a date move is another storno+rattelse layer and carried the guard
  with no override path.
- v1 correct/reverse: run the chain-depth guard before the dry-run
  return so a dry run gives the same verdict as the real execution.
- dashboard reverse route: 400 on malformed JSON or a non-boolean
  allow_deep_chain instead of silently reversing without the override;
  empty body stays the supported no-body case. Tests added.
- AgentChat stream_restart: discard the dead attempt's reasoning and
  re-arm the post-tool paragraph break so a retried turn doesn't render
  thinking twice or glue its continuation onto restored text.
- v1 reverse route doc comment updated for allow_deep_chain.

Not changed: the journal-list reverse flow (flagged as a dead end) can
never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only
for entries that are neither storno nor correction, and such entries
have no backward chain links, so their depth is always 0.

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

* test(bookkeeping): recordate route test expects the new options arg

recordateEntry now takes { allowDeepChain } as a sixth argument; the
route test's called-with assertion predates it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:32:41 +02:00
Mattsson d02fd82191 feat(vat): add per-account declaration treatments (#1588)
Closes #1457
2026-08-13 17:03:35 +02:00
7881f757a9 feat(bookkeeping): show who committed a verifikat, and mark agent work in Granskning history (#1591)
Flows build plan prereq 3 (provenance display). Pure UI over columns that
have existed since migration 20260619120000:

- types: JournalEntry gains committed_actor_type/committed_actor_label
  (the detail/chain APIs already select('*'), the type just lacked them)
- voucher detail: new "Bokford av" row in the Details card, derived from
  actor type + credential label, with the Bot mark for non-user actors
- Granskning Historik rows get the same actor circle pending rows have
  (Bot vs ClipboardCheck) so agent-originated history reads at a glance
- run-turn: correct the staged_operation params comment (tool-use input
  is a superset of pending_operations.params, not the same values)

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:01:15 +02:00
ce6efdb3dc refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)
* refactor(pending): one pending-op-owned preview for chat, /pending and flow views

A staged pending_operation was rendered three separate ways: the /pending
page's OperationPreview switch (8 specialized renderers keyed on
operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers
keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack
that mapped stored operation_types onto 'gnubok_'-prefixed tool names on
hydration. This is the weakest seam ahead of flow-run views (plan seam
8.3): every new operation type had to be taught to render in two places
and silently degraded in the third.

Now there is one owner:

- components/pending-operations/OperationPreview.tsx: the /pending
  renderers moved verbatim, dispatched on operation_type, consumed by
  /pending, ApprovalCard and future flow-run views.
- components/pending-operations/vocabulary.ts: operation labels,
  single-action warnings and the one canonical rejection-category list
  (ApprovalCard's copy was byte-identical and is deleted).
- lib/pending-operations/tool-name.ts: the single translation point
  between bare operation_types and 'gnubok_' tool names, with tests.

toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only
recognized 4 tool names, so a hydrated card for any other operation type
(attach_document_to_transaction, match_transaction_invoice, ...) silently
fell back to a raw generic preview. Hydration now passes the stored
operation_type straight through attachStagedOperations to the card, and
live streamed cards derive it from the event's tool name, so every
operation type keeps its specialized preview on resume.

Per-surface chrome (list row on /pending vs inline chat card) is
deliberately kept: only the preview + vocabulary were the duplicated seam.

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

* chore: drop a stray hunt_title copy rename that rode along

'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was
uncommitted working-tree state from another session, swept into the
extraction commit by git add breadth. It is a product-naming call with
no en.json counterpart and does not belong in this refactor; preserved
in this branch's first commit if it turns out to be wanted.

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

* fix(pending): carry params to chat previews; guard preview amounts

CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders
its DocumentViewButton from params.document_id, which neither chat path
carried: the staged_operation stream event now includes the tool-use
input (the same values the staging tool stored as
pending_operations.params) and hydration selects the params column, so
an attach-document card in chat shows its evidence button live and on
resume. (2) InvoicePreview and CreateTransactionPreview cast amounts
straight into formatCurrency; a payload without one rendered 'NaN kr'.
They now share the same show-the-gap guard the legacy summary already
had.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:17:15 +02:00
Mattsson 22eaab82e5 feat(invoices): add Peppol XML export foundation (#1585) 2026-08-13 16:17:00 +02:00
7ae3477b36 fix(mcp): page list tools past PostgREST's silent 1000-row cap (#1572)
gnubok_list_accounts returned exactly 1000 rows for a full BAS 2026 chart
(1290 accounts) with no truncation signal: PostgREST caps un-ranged selects
at 1000. Wrap the query in fetchAllRows, paging on the unique account_number
and re-sorting by sort_order in JS so the visible order is unchanged. Same
fix for gnubok_list_customers, gnubok_list_suppliers and gnubok_list_articles
(paged on id, re-sorted by name), the Accounted://chart-of-accounts resource,
and the REST v1 accounts.list route. All output schemas and registry metadata
unchanged.

Also note on gnubok_audit_package download_url that the signed URL points at
the Supabase storage host, so restricted-egress proxies may 403; offset the
added prose by trimming the same tool's own descriptions to keep the
tools/list payload under the 59K ceiling.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:30:12 +02:00
9c891ee72d fix(import): SEB CSV imports survive BOMs and bad format choices (#1565)
* fix(import): handle BOMs at the byte level in decodeFileContent

Inspect leading bytes before decoding: EF BB BF strips the UTF-8 BOM and
decodes the remainder (falling back to Windows-1252 for the remainder only,
so the fallback can no longer produce a literal mojibake prefix), and
FF FE / FE FF decode as UTF-16LE/BE. stripBOM additionally strips a literal
mojibake BOM prefix for string paths pre-decoded elsewhere.

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

* fix(import): make an explicit SEB choice at least as good as auto-detect

Three changes for the SEB bank CSV report:

- parseBankFile: when an explicit format parses 0 transactions, fall back
  to auto-detection; a different format that parses rows is returned with a
  prepended info issue naming both formats. A working explicit parse is
  never overridden, and explicit generic_csv (the manual mapping escape
  hatch) is exempt.
- SEB profile: sniff the header delimiter (';' vs ',') and split with the
  quote-aware parseCSVLine; accept a bare Datum date column as a lowest
  priority tier in parse only, never in detect. Its user-reachable issue
  strings are now Swedish.
- Import page: when a parse yields 0 transactions, show the parser's real
  issues instead of only the generic no-transactions hint.

The v1 agent route now decodes through the shared decodeFileContent and
stamps external ids, import_source, and the stored file format from the
format the parse result actually carries, so fallback imports dedup
identically to auto-detected ones.

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

* docs(api-v1): the bank import route also decodes UTF-16

CodeRabbit on #1565: decodeFileContent gained UTF-16LE/BE BOM support
but the route overview and the registered endpoint description still
listed only UTF-8 / Windows-1252. Skill regenerated (apiskill:generate).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:22:19 +02:00
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>
2026-08-13 15:21:55 +02:00
1b829883ae feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)
* feat(reconciliation): accept confidence_threshold on the bank run route

Mirror the v1 route: RunReconciliationSchema gains an optional
confidence_threshold (0..1) that passes through to runReconciliation as
the server-side floor on the apply path. The UI sends 0.85 with a
strong-only apply so a pair the fresh re-run scores lower is skipped
instead of committed; omitting it keeps the legacy behavior where every
selected pair applies.

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

* feat(reconciliation): promote the bulk match flow and bridge it from the inbox

The dry-run preview with pre-ticked strong matches existed but was never
found: users matched whole migrations row by row. Three discoverability
changes, no engine changes:

- Bankavstamning: an attention line above the toolbar while unmatched
  transactions exist and no preview has run, with Forhandsgranska
  promoted to the filled variant. When every ticked preview pair is a
  strong match (>= 0.85) the apply button relabels to 'Matcha X starka
  traffar' and the apply sends confidence_threshold 0.85; mixed
  selections keep the plain label and omit the floor so manually ticked
  weaker pairs still apply.
- Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the
  preview once, only after appliedDates is set and not while datesDirty,
  so it can never cover a different window than the on-screen lists.
- Transactions inbox: with >= 5 unbooked bank rows visible, an attention
  line links to the reconciliation with autorun (static text + count, no
  probe; the preview is the honest source of how many actually match).

The review step stays: autorun lands on the preview table, one click
from apply, and the server intersection guard is untouched.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:27 +02:00
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>
2026-08-13 15:20:14 +02:00
c420fd2aa1 refactor(transactions): drop the Q1-Q4 chips from the period filter (#1579)
The quarter chips (#1545) appeared only once a rakenskapsar was chosen,
pushed the filter row out of alignment, and on a brutet rakenskapsar
they were fiscal-year quarters rather than momsdeklaration quarters, so
they misled more than they scoped. The rakenskapsar filter itself (the
user-requested half) stays; lib/transactions/period-filter.ts keeps its
quarter helpers and tests since resolvePeriodBounds still serves the
full-year bounds.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:17:03 +02:00
d583a4e5bb fix(supplier-invoices): gutter between table columns on the invoice detail (#1578)
The Rader and Betalningshistorik tables carried no horizontal cell
padding, so a right-aligned column ran flush into the next left-aligned
one: the headers read ANTALENHET / A-PRISKONTO / BELOPPVERIFIKATION and
the cells read "1st", "8 985 kr5010" and "11 231 krb9da9d7c". Every
column after the first now gets a pl-4 gutter, matching the dry-table
spacing (TD_CLASS px-4).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:52 +02:00
0643316ac8 feat(import): show SIE import history with undo on the import tab (#1574)
* feat(import): show SIE import history with undo on the import tab

The list route (GET /api/import/sie) and the undo route (DELETE
/api/import/sie/[id]/undo) both existed, but no UI ever called the list:
once the post-import result screen was gone, past imports could not be
seen or undone. Add a fold-open 'Tidigare SIE-importer' row on the
Importera tab (same expanded pattern as the cloud-backup row) that
lazy-loads a history table: filename, date, fiscal year, voucher count,
status, and an undo button on completed rows. Undo confirms through
DestructiveConfirmDialog (voucher count, IB cleared, documents detached
but kept; plus a voucher-gap warning for large imports), keeps the
dialog open for the long-running DELETE, and refetches on completion.

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

* test(import): cover the SIE list and undo routes

The base list route had no test file (its siblings all do) and the undo
route was only covered indirectly. Add route tests through the real
withRouteContext wrapper: 401, the { data, count, limit, offset } shape
with company scoping and range math, the status filter, and the
Swedish 500 path for the list; 401, 403 viewer, the
{ success, deletedEntries } passthrough, and the SIE_UNDO_FAILED
envelope (reason in details) for undo, with undoSIEImport mocked.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:41 +02:00
e1f13f870a feat(import): warn about already-imported rows in the bank-file wizard (#1567)
* fix(transactions): paginate the ingest dedup maps past the 1000-row cap

buildExistingTransactionMaps issued un-paginated selects for the booked and
unbooked dedup maps, so PostgREST silently truncated each at 1000 rows: a
re-import over a wide date range in an active company deduped against a
partial map and inserted everything past the cap as duplicates. Both queries
now go through fetchAllRows with a stable .order('id') for range paging.

Also exports the function and its types for the upcoming read-only duplicate
preview, which must share the exact stored-row universe execute-side ingest
dedups against.

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

* feat(import): add read-only duplicate preview endpoint for bank files

New POST /api/import/bank-file/check-duplicates (withRouteContext + Zod,
transactions capped at 20000) computes external_ids with the exact
generateExternalId(tx, format, index) derivation execute uses and runs
previewDuplicates: Layer-1 id collisions plus the Layer-2 text bridge with
counting semantics and the currency guard, against the same stored-row maps
ingest builds (buildExistingTransactionMaps). The result is advisory; execute
stays authoritative and mirrors/settlement-account guards are documented
preview/execute differences.

A dedicated endpoint because the generic_csv path re-parses client-side and
never re-hits /parse. Also removes the dead existing_transaction_count field
from the parse response (a raw date-range count consumed by nothing).

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

* feat(import): surface duplicate rows in the bank-file import wizard

Overlapping bank imports used to dedup silently: the wizard promised
'Importera N transaktioner', ingest skipped the twins, and the user saw fewer
rows than parsed with zero explanation. The wizard now calls check-duplicates
after a successful parse AND inside handleColumnMappingConfirm (the
generic_csv path never re-hits parse), and:

- BankFilePreviewStep: warning card in the AlertTriangle pattern ('{count}
  rader finns redan', skipped automatically) plus a 'Finns redan' badge on
  flagged rows in the 50-row table
- BankFileConfirmStep: repeats the summary card (generic path skips preview)
  and the CTA counts 'Importera {parsed - duplicates} transaktioner'
- BankFileResultStep: renders result.duplicates when > 0, closing the loop
  ingest.ts documents as unrendered

Execute semantics unchanged: all rows are sent, ingest skips; the preview is
advisory and never promises an exact final number. New strings in both
messages/sv.json and messages/en.json next to the import_psd2 anchors.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:29 +02:00
78a581bca1 fix(import): guard against CP437-as-CP1252 mojibake entering via pre-decoded SIE text (#1569)
* refactor(arcim-migration): remove the dead gateway SIE export path

fetchSIEExport and SIEExportFile have had zero callers since the direct
provider clients replaced the Arcim Sync gateway (#181, #718). The path
returned SIE as a pre-decoded string, and the gateway's decode of CP437
bytes as windows-1252 is what wrote the 2026-03-17 mojibake into posted
entries. Deleting it makes the string-typed SIE fetch impossible to
re-wire; a comment marks the grave. The consent lifecycle and entity
accessors stay untouched.

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

* fix(import): warn when SIE text carries CP437-as-CP1252 mojibake

The 2026-03-17 migration wrote mojibake ("L"neutbetalning"-style C1
specials) into posted entries because the retired gateway handed the
/import-sie handler an already-decoded string: byte-level encoding
detection never saw it, and nothing downstream checked. The live bug is
gone; this is the tripwire so the signature can never land silently
again.

- lib/import/sie-artifact-scan.ts: pure scanner over parsed SIE account
  names and voucher/line descriptions, reusing hasCp1252Artifact from
  charset-repair; flags at >= 2 hits so a lone legitimate curly quote or
  apostrophe cannot false-positive a whole file.
- arcim-migration /import-sie: warn-never-block; the Swedish warning
  rides on result.warnings, which the workspace UI already renders, plus
  a server-side log.warn.
- wizard parse route: same scan, surfaced through the existing
  parse-issue warnings card in the preview, pointing at the first
  affected line.

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

* test(bookkeeping): pin the reported gateway mojibake strings

Adds the four strings reported from the affected company's journal as
reverse_cp437 cases (all reverse losslessly) plus a false-positive
guard: space-padded typography must never route into the CP437
reversal.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:14:23 +02:00
c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:32 +02:00
MattssonandClaude Fable 5 5769e35869 fix(api-v1): propagate underlag when booking via v1 categorize routes (#1564)
The v1 categorize and batch-categorize routes create the journal entry
via createTransactionJournalEntry directly and never ran the shared
underlag propagation, so a booking made through the API-key surface
left the transaction's pinned document unanchored and matched inbox
items unstamped: the same "Underlag saknas" gap #1560 closed for the
dashboard, /book and bulk-book paths, surviving on this one surface.

Both routes now call propagateUnderlagForBookedTransaction after the
CAS write succeeds (only when this request owns the booking; skipped on
partial success and lost CAS races). Best-effort by contract, same as
every other caller: a propagation failure is logged inside the helper
and never fails the booking.

Also adds the attach-after-bulk-book unit test salvaged from the closed
duplicate PR #1559: a document pinned to a bulk-booked transaction
(verifikat anchored via transaction_voucher_links) is anchored against
the samlingsverifikat when attached after the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:48:01 +02:00
MattssonandClaude Fable 5 d9dddba682 fix(transactions): anchor the pinned document to the verifikat on booking (#1560)
A document pinned to a transaction (transactions.document_id) with no
unconsumed inbox item was never anchored onto the verifikat when the
transaction was booked: document_attachments.journal_entry_id stayed
null and every underlag surface reported "Underlag saknas" for a
booking that HAS its underlag (attach-before-book via the manual
booking dialog, the 2026-08-13 user report).

PR #1547 already routed /book, bulk-book and categorize through the
shared propagateUnderlagForBookedTransaction helper, but that helper
only walked matched inbox items. This adds a pinned-document leg to the
helper, so all booking paths anchor the pin in one place:

- the pin is read fresh inside the helper (not from the caller's
  pre-booking snapshot) so a concurrent attach is still anchored
- same guard semantics as inbox docs, via the extracted
  anchorDocumentToJournalEntry: no-op when already anchored to this
  verifikat, never steal another verifikat's underlag, log-and-continue
  on failure (the booking is already posted; a re-run repairs the link)
- the bulk-book RPC already anchors pins atomically, so the leg no-ops
  there

Route tests cover the three plan cases: pinned doc anchored, matched
inbox item stamped, and propagation failure never failing the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:50:03 +02:00
314efe8b22 fix(design): stop synthesizing bold on Hedvig display headings (#1555)
* fix(design): stop synthesizing bold on Hedvig display headings

Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule
forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold
on top, so every display heading rendered browser-synthesized bold: the
smudged heavy look on dialog titles and page headings. Drop the base
rule to 400, remove the weight utilities from the title primitives, and
sweep the 41 files that hand-set font-medium/semibold/bold on serif
headings (a pattern design.md already forbids). Headings that opt into
font-sans keep their weight.

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

* fix(design): drop empty className left by the weight sweep

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:27:25 +02:00
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>
2026-08-13 11:20:16 +02:00
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>
2026-08-13 11:20:00 +02:00
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>
2026-08-13 11:19:45 +02:00
5453f11330 fix(design): keep sortable table headers uppercase (#1558)
The sortable headers on /bookkeeping and /invoices wrap their label in a
button, and preflight's text-transform: none on buttons swallowed the
uppercase from TH_CLASS, so sorted lists showed sentence-case heads next
to every other table's uppercase idiom. Re-apply uppercase on the sort
control itself.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:19:35 +02:00
18755a37ec fix(underlag): render the workspace skeleton while the route loads (#1556)
The extension loading.tsx branched on an x-pathname request header that
middleware only ever set on the response, so the pathname always read
empty, the fullscreen branch was dead code, and Dokumentinkorg loaded
behind the old inline-shell silhouette. Branch on the client pathname
instead (same convention as the dashboard loading.tsx), and extract the
skeleton into one shared component used by both the route fallback and
the workspace's client-fetch state, updated to the post-rebuild layout:
full-bleed, no card wrapper, filter dropdown instead of the removed
pill row.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:19:00 +02:00
8f1b1fb5cd feat(ui): company monogram in user menu + mobile web touch polish (#1531)
* feat(ui): replace generic building icon with company monogram in user menu

The company row and switcher flyout in the sidebar user menu showed
lucide Building2 for every company. Render the company's initial in a
small rounded square instead (square = company, circle = person), so
each company gets a mark of its own.

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

* fix(mobile): touch behavior polish for the mobile web experience

- kill -webkit-tap-highlight-color flash; touch-action: manipulation on
  interactive elements (no double-tap-to-zoom wait); user-select: none
  on buttons (long-press no longer enters text selection)
- overscroll-behavior-y: contain on html/body: pull-to-refresh no longer
  hijacks list scrolling, inner scrollers stop chaining to the document
  (contain, not none, so iOS rubber-banding survives)
- 16px font-size floor for form fields on coarse pointers: iOS Safari
  stops zooming into focused inputs; desktop keeps text-sm
- min-h-screen -> min-h-dvh everywhere: correct height under collapsing
  mobile browser chrome, identical on desktop
- active: variants mirror hover: on Button: Tailwind 4 gates hover:
  behind (hover: hover), so touch devices previously got zero pointer
  feedback
- theme-color now tracks the app: default was a leftover blue #304D83;
  SSR emits white and ThemeColorSync mirrors the computed --background
  into the meta tag across dark mode and palette switches

Hover-stuck-after-tap and viewport-fit/safe-area were already covered
(Tailwind 4 hover gating; existing viewportFit: cover + safe-area
utilities).

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

* docs: log monogram and overscroll decisions

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:41:45 +02:00
MattssonandClaude Fable 5 b9bf60234d feat(transactions): filter /transactions by rakenskapsar and kvartal (#1545)
* feat(transactions): filter /transactions by rakenskapsar and kvartal

User request: booking a specific period (including brutet rakenskapsar,
e.g. July-June) meant scrolling past every other year's transactions.

- New FyPicker chip in the toolbar scopes both the inbox and history views
  to a fiscal year; quarter chips (Q1-Q4, fiscal-year aligned) appear once
  a year is selected. Clicking the active quarter widens back to the year.
- Bounds are pushed into the Supabase queries (window, pending backlog,
  badge count, load-more) so pagination and the Att bokfora count stay
  consistent with the visible list; skattekonto rows are bounded client-side.
- Scope persists under a page-local localStorage key, deliberately separate
  from the shared report scope so a year picked on a report page never
  silently hides pending inbox rows.
- lib/transactions/period-filter.ts derives quarter bounds from fiscal
  period dates (handles brutet, shortened and extended years); unit tested.
- FyPicker gains an optional storageKeyPrefix prop; default unchanged.

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

* fix(transactions): never hide pending rows behind the period filter

Swedish accounting review on PR #1545: scoping the pending-backlog fetch
and badge count to the period made unbooked rows outside the selected
year vanish from the inbox worklist (BFL 5 kap: pending affarshandelser
must stay visible until booked).

- Pending-backlog fetch and the DB pending count are unscoped again;
  only the history window pages server-side within the period.
- The inbox applies the period client-side over the complete backlog;
  the tab badge counts pending rows inside the scope.
- When pending rows (bank or skattekonto) fall outside the scope, the
  footer says how many and offers Visa alla, which clears the filter
  and its persisted value.

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

* fix(transactions): guard list fetches against stale cross-scope responses

CodeRabbit on PR #1545:
- fetchTransactions/loadMoreTransactions now carry a fetch generation;
  a response applies only if no newer fetch (scope change, realtime
  refresh, load-more) started meanwhile, so a slow pre-filter request
  can no longer overwrite the active period scope's window, paging
  offsets, or loading skeleton.
- FyPicker restore effect includes storageKeyPrefix in its deps.

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

* fix(transactions): label quarter chips as fiscal-year quarters

Swedish accounting review note on PR #1545: Q1-Q4 follow the company's
rakenskapsar, which on a brutet rakenskapsar differs from the calendar
quarters that momsdeklaration periods use. Say so in the group's
aria-label and hover title so the chips are not mistaken for VAT
periods.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 01:21:31 +02:00
MattssonandClaude Fable 5 8d56219c31 fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever

A matched inbox item only left the active inbox when
created_journal_entry_id was stamped, and only categorizeTransactionCore
stamped it. Booking the matched transaction through any other path (the
/book dialog route, bulk-book, link-to-existing-voucher) or matching a
receipt to an already-booked transaction (receipt hunt approvals,
attach-document, match-transaction) left the item "linked" forever,
pointing at a transaction that had already left the transactions work
list. Todays hunt fix (#1524) turned this July-old gap into a visible
flood of stuck items.

Two-part fix, because stamps alone cannot cover the reported case:
created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book
samlingsverifikat only one of N matched items can ever carry it.

Write side: lib/transactions/inbox-underlag.ts is the shared
implementation all paths now call. It links matched items' documents to
the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the
verifikation) and stamps created_journal_entry_id best-effort (CAS on
null, unique_violation tolerated). Wired into categorize-core (replacing
its inline block), /book, bulk-book, linkTransactionToJournalEntry, both
attach paths (REST + pending-operation), and the inbox match-transaction
handler. The attach paths and the doc-conflict guard also resolve
bulk-booked transactions through transaction_voucher_links, which they
previously treated as unbooked.

Read side: GET /items (and /items/:id) enrich matched-but-unstamped
items with matched_transaction_journal_entry_id, and the workspace
derives "booked" from it. This is what clears the stuck rows already in
prod without a status backfill, and what covers the N-1 samlingsverifikat
items the UNIQUE constraint refuses to stamp. Bulk-book selection
filters exclude such items so "Bokfor valda" no longer offers 409 fodder.

scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs
the historical document->verifikat links the old paths never made.

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

* fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik

Both from the Swedish accounting compliance review.

The consumed-stamp is now conditional on the underlag actually
referencing a verifikat: stamping over a failed document link hid the
item from the .is('created_journal_entry_id', null) query forever,
leaving a posted verifikation without its underlag reference
(BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed
link now leaves the item unstamped so re-runs and the backfill can
finish the job; a document preserved on another verifikat still counts
as settled.

The backfill script now appends an InboxUnderlagBackfilled event per
repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass
repair touching underlag-to-verifikat linkage leaves a changelog trail
distinguishing it from the original booking action.

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

* refactor(inbox): backfill writes behandlingshistorik through the shared appender

From the Swedish accounting compliance review round 2: a hand-rolled
processing_history insert in the backfill script could drift from the
shared row shape and skip the PII validation. appendProcessingHistory
now delegates to appendProcessingHistoryWithClient, which takes a
caller-supplied service-role client, so standalone scripts write
behandlingshistorik through the exact same code path as the app
(BFNAR 2013:2 kap 8: one reconcilable change log across writers).

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

* fix(inbox): leave the item unstamped when its document belongs to another verifikat

Swedish accounting review round 3: refusing to steal the document was
right, but stamping the item consumed anyway hid the fact that the
transaction's own verifikat ended up with no underlag reference from it
(BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves
created_journal_entry_id null so the mismatch keeps surfacing for
reconciliation, same posture as a failed link.

Also documents in the backfill script header why its writes cannot land
in locked periods: linkToJournalEntry's UPDATE is guarded by the
enforce_period_lock DB trigger, which fires for service-role writes too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:49:20 +02:00
MattssonandClaude Fable 5 45d7f1be4e feat(mileage): surface Körjournal in the nav behind a settings toggle (#1540)
* feat(mileage): surface Körjournal in the nav behind a settings toggle

The /mileage page shipped hidden: the route works but no nav row points at
it. Add company_settings.mileage_enabled (mirroring dimensions_enabled) with
a switch in Fönster -> Bokföring, and show the Arbeta nav row when the toggle
is on OR the company already has mileage_trips rows, the same hybrid gate as
webshop orders, so trips created via API/MCP can never become invisible
underlag. UI visibility only, never load-bearing for correctness.

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

* fix(migrations): move mileage_enabled migration after already-applied 20260812153208

origin/main merged in 20260812153208 which prod has already applied; a new
file sorting before it risks an out-of-order db push abort.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:48:01 +02:00
67febd5097 feat(ui): prev/next record navigation on detail pages (#1530)
* feat(ui): prev/next record navigation on detail pages

Customer feedback: stepping between invoices in a reskontra (56 -> 57 ->
58) required going back to the list for every record.

List pages now write their full ordered id array to sessionStorage when
a row is opened (Accounted:list-context:<scope>:<companyId>), and the
detail pages for kundfakturor, leverantorsfakturor, and verifikat show
a compact prev/next pager (chevrons + 'n av m') next to the back
control. ArrowLeft/ArrowRight step too, except while typing in a text
field or while a dialog is open. Navigation uses router.replace so
'tillbaka' returns to the list in one step. Deep links and new tabs
have no context: the pager hides and pages behave as before.

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

* fix(pager): overlay-aware arrow guard, notes-draft safety, context on Visa detaljer

Review fixes on the detail-record pager:

- The keyboard guard matched any mounted [role=dialog], so the agent
  sheet (which stays mounted display:none once opened) killed arrow
  paging for the rest of the tab session, while open dropdown menus
  did not block at all. The guard now mirrors the AgentSheet Esc
  selector (data-state="open" variants incl. alertdialog and radix
  menu/select/listbox content) and also yields while focus sits inside
  a dialog/menu/listbox container. Extracted as pure functions in
  lib/hooks/detail-pager-guards.ts so the rules are testable in the
  node test environment.
- Arrow keys could unmount the verifikat page and destroy an unsaved
  notes draft once the textarea lost focus. useDetailPager and
  DetailPager now take a keyboard flag, and the verifikat page
  disables keyboard paging while editingNotes is active; the chevron
  buttons stay live.
- The expanded-row Visa detaljer link in JournalEntryList navigated
  without writing the list context, producing stale pager snapshots;
  it now calls rememberListContext like the voucher link.
- ListContext.listPath was written and strictly validated but never
  consumed: removed from the interface, all writers, and the read
  validation. Reads stay tolerant of extra properties so contexts
  stored by older builds still parse.

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

* refactor(pager): quiet wayfinding strip instead of buttons in the title cluster

The pager sat between the back arrow and the H1, which read as a toolbar
of three boxed buttons and made the title jump horizontally per record.
All three detail pages now share the verifikat page's pattern: a muted
back text-link on the left and the pager right-aligned on the same quiet
row. The pager itself drops to 16px glyphs, muted ink, and hides when the
list context holds a single record.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:07:57 +02:00
bffa57a565 feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)
* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA

Customer feedback: MCP-created invoices land in Granskning and then sit as
unnumbered drafts that each need individual issuance, and the list filter
gives no signal about where the work is.

- New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent
  semantics (no email) and book inline when the company books at issue;
  sent/overdue unbooked invoices get the deferred /book semantics.
  Sequential loop keeps voucher numbers ordered; per-item Swedish errors.
- Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts
  and lib/invoices/book-invoice-deferred.ts, now used by the per-id
  mark-sent and book routes AND the bulk loop, so they cannot drift.
  Per-id route behavior unchanged (existing route tests untouched, green).
- Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices
  shape), bulkbar with mode-aware action label, ConfirmationDialog with a
  draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection
  entirely.
- ContextPicker: count annotations on every status view via the one shared
  predicate (counts always match rows), active view written back to the URL
  (?status=) for shareable views. No seg/chip row: founder-locked pattern.
- Granskning: after a bulk approve that committed create_invoice ops, the
  summary toast links to /invoices?status=draft to finish with bulk Bokfor.

Verified: npm run lint clean, npm test 13845 passed, npm run check:guards
passed. New tests: bulk-book route (11), issueAndBookInvoice (7),
bookInvoiceDeferred (7).

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

* fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params

- Deferred-booking companies (accrual + defer_invoice_booking): a draft in
  bulk-book no longer gets silently ISSUED (F-number consumed, marked sent,
  invoice.sent emitted) while reporting status 'booked' with a null
  journal_entry_id. The draft branch now requires booksInvoicesOnIssue();
  otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT
  code (Swedish + English) before the invoice is touched.
- Duplicate ids in one request no longer double-book: the second iteration
  read the stale pre-loop snapshot, passed the already-booked check, and
  minted a voucher the CAS claim then cancelled (cancelled verifikat + gap
  explanation per duplicate). Ids are deduped before the loop.
- Bulkbar: the select-all link is hidden when the current view has no
  selectable rows; "Markera alla (0)" only wiped the existing selection.
- Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT
  payout) rewrite only their own query keys instead of hardcoding
  '/invoices', so the ?status= view write-back survives them.
- /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod
  and deferred-booking companies where the invoice list offers no draft
  bulk Bokfor (dead end); the neutral hint sentence stays.

Tests: deferred-draft rejection (asserts issueAndBookInvoice never called,
sent invoice in the same batch still books) and duplicate-id dedupe (exactly
one booking call); both fail without the route fix.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:02:27 +02:00
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>
2026-08-12 21:00:57 +02:00
7cf0e34434 feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines

Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP
beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium
(SLF 1991:687). The item-based debit-only form could not express the
self-balancing pair, so users had to hand-edit the verifikat.

- new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source,
  re-exported by the bokslut calculator), isSlpPensionAccount (741x),
  generateSlpLines (7533 D / 2514 K, nets to zero)
- migration adds supplier_invoice_items.apply_slp boolean default false
- registration, cash, and privately-paid generators inject the pair for
  flagged 741x items, mirroring the reverse-charge injection; the balance
  guarantees keep 2440/1930/2893 at exactly the invoice total; the credit
  note generator reverses the pair (7533 K / 2514 D)
- privately-paid balance guarantee now subtracts existing credits so the
  SLP 2514 leg never inflates the owner account
- schema field apply_slp + guards in all create paths (main route, inbox
  convert, v1 REST, pending-operations executor): 400
  SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400
  SI_CREATE_SLP_ACCRUAL combined with periodisering
- form: advisory hint on unflagged 741x rows with one-click opt-in and a
  quiet confirmation line when applied; totals box untouched (the invoice
  total stays the payable); AB review preview injects the same pair via
  the same generator for parity
- year-end double-count guard: calculateSarskildLoneskatt subtracts SLP
  already posted to 7533 during the year (floored at zero) so bokslut
  never provisions flagged premiums twice

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

* chore(api-skill): regenerate suppliers reference for apply_slp

The apiskill:check CI gate requires the generated accounted-api skill to
stay in sync with the endpoint registry after the apply_slp addition.

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

* fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal

Review findings on the SLP PR:

- v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so
  createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514
  pair booked at registration (it previously stood forever and the
  year-end netting under-provisioned). The flag is also copied onto the
  created credit-note items for parity with the web credit route.
- v1 mark-paid: the items sub-select now includes apply_slp, so a
  kontantmetoden payment via v1 books the cash entry WITH the SLP pair,
  matching the web mark-paid.
- v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag
  is readable back through the public API.
- credit-note SLP base is abs of the SIGNED sum of flagged line_totals,
  not per-item abs: a mixed-sign flagged original (+10000/-2000) booked
  SLP on 8000 at registration and now reverses exactly that, not 12000.
  The expense-bucket per-item abs convention is untouched.
- kontantmetod bank-match preview appends the same generateSlpLines pair
  the POST books, so the approved lines equal the committed lines.
- MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts
  apply_slp (optional boolean), plumbs it into the staged operation's
  items, and rejects non-741x resolved accounts at staging time with the
  bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts.
- DECISIONS.md: five entries for today's decisions.

Every behavioral fix has a test verified to fail without 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>
2026-08-12 20:52:47 +02:00
MattssonandClaude Fable 5 2fb3667e8d fix(webshop-orders): use a valid customer_type when creating the customer from an order (#1538)
* fix(webshop-orders): use a valid customer_type when creating the customer from an order

Converting a business order (customer_company set) to an invoice inserted
the customer with customer_type: 'business', which
customers_customer_type_check rejects (allowed: individual,
swedish_business, eu_business, non_eu_business). Every first-time business
order conversion 500ed with WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED;
individual orders and already-known customers were unaffected.

Map to 'swedish_business': scraped store data carries no reliable country
signal, and the draft review plus the customer card remain the gate where
the user corrects the classification.

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

* fix(webshop-orders): classify the created customer by the order's billing country

Swedish compliance review on #1538: a flat 'swedish_business' default would
treat EU and non-EU business customers as domestic, charging Swedish VAT
where reverse charge (ML 17 kap 24 p.11) or export treatment applies. The
order snapshot carries the billing country, so use it: SE or missing ->
swedish_business, EU member -> eu_business, otherwise non_eu_business.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:55:00 +02:00
MattssonandClaude Fable 5 11995b1b0c feat(auth): make automatic logout an opt-in per-user setting (#1536)
* feat(auth): make automatic logout an opt-in per-user setting

Session timeouts (30 min idle / 12 h absolute on hosted) now apply only
to users who enable "Automatic logout" in Settings > Security. Default
is off: sessions live for the full Supabase refresh-token lifetime, the
behavior from before the 2026-07 session hardening.

- user_preferences.auto_logout (migration, default false), toggled via
  the extended /api/user/preferences route
- The opt-in is snapshotted into the signed timeout cookie at mint, so
  enforcement stays DB-read-free per request; the preferences route
  clears the cookie on change so a toggle takes effect immediately
- Pre-toggle cookies are authentic-but-stale: re-minted preserving
  their timers, never routed down the tamper path, so the rollout does
  not log anyone out
- NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for
  every user regardless of preference (emergency lever, also plumbed
  through the Docker image); self-hosted stays disabled by default

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

* fix(auth): resolve PR #1536 review findings

- Replace the spread upsert in /api/user/preferences with one literal
  payload per field: the phantom-column schema guard cannot resolve
  spread payloads (Unit tests 3/4 ceiling failure)
- Map the preferences 500 through getErrorMessage so the user-facing
  text is Swedish (CodeRabbit)
- fetchAutoLogoutPreference now returns null on a FAILED read instead
  of a fail-open false: callers skip minting so an unknown preference
  is never persisted into the year-long signed cookie, and the next
  request retries; failures log at error level, distinct from the
  normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5)

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

* fix(auth): write multi-field preference updates as one atomic upsert

A request carrying both hide_assistant_fab and auto_logout previously
issued two sequential writes, so a failure of the second returned 500
after half the request had persisted (CodeRabbit, PR #1536). One
literal upsert per accepted field combination keeps the write atomic
and stays resolvable for the phantom-column schema guard.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:45:41 +02:00
2e2a64dd0a fix(inbox): say each thing once, and stop explaining what doing it teaches (#1532)
* docs(inbox): the onboarding card described the page as it used to be

Three steps ending at 'matcha mot en transaktion eller bokför', a Beta
badge it had outgrown, and no mention that the page now searches the
mailboxes itself, lists the purchases missing a receipt, or proposes the
kontering.

It now names the three things a person actually does: get an address,
connect a brevlåda so Kvittojakten can look on its own, and approve the
proposed kontering. The pricing line keeps the distinction that matters
(collecting underlag is free; AI-tolkning and the hunt are in the plan)
and drops the Beta badge.

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

* fix(inbox): say each thing once, and stop explaining what doing it teaches

Six things the page said that it did not need to say, or said twice.

The mailbox rows misaligned because the address could not shrink: a long
one pushed the date and Koppla från onto a second line while the provider
mark stayed centred against a now two-line row. min-w-0 lets truncate work.

The settings group was labelled Leta efter underlag directly above a row
labelled Sök igenom brevlådorna. The group is now Kvittojakten, which is
what the rest of the app calls it.

A hunt that found nothing left its line on screen indefinitely. There is
nothing to act on, so it clears after a few seconds. A run that found
something, or failed, still stays: both name a next step.

Ändra kontering opened with no rows at all for an unknown supplier, so
the first move was Lägg till rad before anything could be typed. The form
already defaults to two blank rows when given nothing, but an empty
proposal was passed as [] rather than undefined, which is not the same.
Its description restated the title, and the keyboard tips sat under every
entry form permanently; both are learned by doing.

The matched state was stated twice in one rail, as a bordered box and a
badge. The badge keeps it, and takes over the box's link to the
transaction.

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

* fix(pending): name the account, not just the line text

A proposal read '5890 Utlägg Norwegian'. 5890 is Övriga resekostnader,
which the preview never said: it printed the account number next to the
line's own description, so the only readable word on the line was one the
proposal wrote about itself. Approving meant trusting a label that never
named what was being debited, and a travel cost looked like an utlägg.

The account's own name now leads, with the line text after it when it
says something the name does not. Same for the VAT lines.

Fetched once and shared across previews; a failed lookup leaves the
number rather than blanking the line.

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

* fix(pending): the account-name map must not outlive the company

Review finding, and the tenancy half is the real one. The lookup was a
module-level promise populated once with ??= and never invalidated, so a
company switch that does not reload the page would keep showing the
previous company's account names against this company's numbers: a wrong
name reads as verified in a way a bare number never does.

It is also permanent on failure. A single transient error resolved the
cached promise to {} for the rest of the session, with no retry short of
a reload.

The page owns it now and passes it down by context: one fetch per mount,
gone when the page is, and a failure leaves the bare number and retries
next time.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:13:58 +02:00
MattssonandClaude Fable 5 c35b2547fb feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface

webshop_orders (order/refund rows, financial-freeze trigger, member
select/update RLS, no DELETE) + webshop_store_settings (per-store payment
method -> account map), source_type 'webshop_order', multi-store index drop,
customer_country, and a one-time woo cursor reset so the switch-over
backfills and cross-marks existing feed rows. Tables classified in the
full-archive export; pg-real coverage for RLS, freeze and CHECK.

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

* feat(webshop-orders): core service (ingest, booking lines)

upsertWebshopOrders: two-phase order/refund upsert with FX enrichment,
legacy-feed cross-marking, frozen-row protection and field-wise jsonb
comparisons (Postgres does not preserve object key order). Booking-line
builder: per-rate VAT split with SIGNED buckets (discounts book as revenue
reductions), refund mirroring, 3740 residual, per-store account prefill,
and advisory export/EU + OSS warnings.

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

* feat(webshop-orders): API routes for list, booking, invoicing and mapping

Booking is draft -> atomic claim -> commit (conditional link-back closes the
concurrent double-book race; a lost claim cancels the voucher-free draft).
Legacy-feed guard honors transactions.is_ignored on both the book and
create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an
unnumbered draft with dominant-rate fallback and drift-safe unit prices.

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

* feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav

/orders lists per-store orders with status tabs (server-side filters),
exception chips and one action per row. Booking dialog prefills from the
per-store payment-method mapping with an opt-in remember; invoice dialog
converts to a draft kundfaktura. The Order nav item renders only for
companies with an active WooCommerce connection or existing order rows
(Shopify deliberately excluded until its sync writes webshop_orders).

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

* feat(woocommerce): switch the order sync to webshop_orders, multi-store

The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund
allocations with parent-prorated VAT fallback) and upserts order rows
instead of transactions-inbox rows; already-imported feed rows stay
bookable and get cross-marked. Multi-store: several active connections per
company, per-store panel cards with the account-mapping editor.

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

* docs(webshop-orders): decision log entries and ratchet baseline

Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption;
hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined).

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

* fix(webshop-orders): resolve PR #1525 review findings and CI failures

Review batch (Superagent, CodeRabbit, Swedish compliance review):
- Mutual-exclusion claims: booking guards invoice_id, invoice link-back
  guards journal_entry_id AND treats zero matched rows as the conflict it
  is (409 + rollback), closing both TOCTOU races.
- Freeze v2 migration (20260812124858): the link columns themselves are
  protected: invoice links immutable, journal links clearable only while
  the entry is still a draft (the booking rollback path).
- Scraped orgnr no longer auto-written to customers.org_number; rate
  fallback applies only on single-VAT-bucket orders; refunds get their own
  WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the
  invoice-mode hint in the booking dialog.
- Ingest compares every synced field (billing corrections no longer drop
  as unchanged); sync guards absent refunds arrays; /sync aggregates
  per-store results; panel disables all cards while a request runs; orders
  page separates load failure from empty; account field explains itself.

CI: regenerated skills/accounted-api; pg tests restructured for
transaction-abort/rollback semantics + freeze-link coverage; unresolvable-
expression ceiling 375 -> 378 with documented reason (partial-update
payloads in ingest, shapes covered by unit tests).

Declined: CodeRabbit docstring-coverage advisory (house style: comments
only where the code cannot say it).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:08:13 +02:00
89cfa82f2f feat(inbox): show the hunt working, not just spinning (#1526)
A pass runs for over two minutes and reports nothing until it lands. The
only sign of life was a spinner inside a button and a number that did not
move, so the honest reading was that nothing was happening.

A band under the header now names the mailboxes actually being read, keeps
a running count of omgång and hämtade as passes land, and offers Avbryt
without hunting for it in Settings.

The bar is deliberately indeterminate. There is no honest percentage
inside a pass, and inventing one to look finished is worse than admitting
the wait. Reduced motion holds the sweep still and dims it instead of
removing it, because the band still has to read as active.

The result line waits for the run to end rather than sitting under a live
one, so a press replaces itself in place.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:59:51 +02:00
MattssonandClaude Fable 5 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>
2026-08-11 23:12:18 +02:00
f2d9e98af3 fix(mail): stop Gmail refusing the search, and stop calling that "hittade inget" (#1521)
Pressing Leta produced mails=25, documents=0 on a real two-mailbox run.
Nothing was found because nothing was searched: every request came back
429 "Too many concurrent requests for user".

Two bugs, and the second is the one that matters.

The search fanned out with Promise.all over every message id at once, one
Gmail request per message, per connection. Gmail enforces a per-user
concurrency ceiling as well as a daily quota, and this sailed past it long
before any volume worth worrying about. It now runs through a pool of five
per connection, which is comfortably under and still finishes a page of
results in a couple of round trips.

The catch turned each refusal into an empty array, with a comment saying
one mailbox's failure must not become the company's. Right instinct, wrong
consequence: an empty array is also what an empty mailbox returns, and the
manual hunt loop stops on fetched === 0 because that is its signal for
"the mailboxes hold nothing more for what is open". So a rate-limited
search told the user their receipts do not exist, and stopped looking.

searchFailureCount() now separates "could not look" from "nothing there".
The run route reports it, and the loop treats a pass with failures as
failed rather than finished, so pressing again is the obvious next move
instead of a pointless one.

This is the failure this feature exists to catch, happening inside the
feature: silence that reads as an answer.

Restoring the unbounded fan-out fails one test; removing the failure
counter fails three.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:23:58 +02:00
11b82cbb91 feat(api): installable accounted-api agent skill + openapi-to-skill generator (#1516)
* feat(api): installable accounted-api agent skill + openapi-to-skill generator

Three layers, per the July/August 2026 agent-skills ecosystem (skills.sh /
npx skills add, as used by Stripe/Cloudflare/Supabase for their APIs):

- skills/openapi-to-skill/: generic, installable skill that turns any
  OpenAPI spec into a consumer-side integration skill, with a portable
  stdlib-only inventory/condenser tool and an output template + quality
  checklist encoding the distill-not-restate methodology.
- skills/accounted-api/: the installable skill for our own API, rendered
  deterministically by scripts/api-skill/generate.ts from the v1 endpoint
  registry + hand-authored overlays (auth, conventions, domain gotchas).
  CI gate: npm run apiskill:check (core-build.yml).
- lib/api/v1/registry.ts: generateOpenApiSpec now emits requestBody (incl.
  multipart binary parts) and path parameters, and the Zod converter learned
  .default()/z.record()/.pipe()/.transform(), so the public spec carries
  request contracts instead of prose-only.

Docs: /docs/api landing + /llms.txt now point agents at the skill install;
corrected the stale test-key description in the landing (test keys read
real data and force dry-run writes; they are not sandbox-company bound).

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

* fix(skills): escape backslashes in markdown table cells (CodeQL js/incomplete-sanitization)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:45:19 +02:00
f0bedc14af feat(payments): betalfil UI (betalfil 3/3) (#1505)
* feat(payments): betalfil UI (betalfil 3/3)

Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list,
preview dialog with per-line editable amount/date and exclusion reasons,
payment-files history page with re-download, cancel and a sequential
bulk mark-paid (duplicate guard respected, never forced), I betalfil
chip on rows in active batches, clearing/kontonummer fields on the
supplier form, and the supplier_payment_files namespace in sv+en.

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

* fix(sandbox): make the demo AP data betalfil-ready

Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo
invoice had remaining_amount 0 (no trigger derives it, so the list said
0 kr kvar att betala), and the company had no IBAN/BIC, all of which
excluded the seeded data from the betalfil flow. Numbers swapped for
Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added,
and both bulk-insert rows set remaining_amount explicitly per the
PostgREST normalization rule already documented inline.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:07:04 +02:00
576a34750a feat(payments): payment-batch API surface (betalfil 2/3) (#1504)
Preview/create/list/get/file/cancel routes under
/api/supplier-invoices/payment-batches, SI_BATCH_* structured errors,
and a fail-closed batch-membership pre-check in the supplier invoice
DELETE route (the FK RESTRICT is the backstop). File downloads stamp
file_generated_at + download_count but regenerate byte-identically.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:00:42 +02:00
1d635b0d25 feat(receipt-hunt): look for receipts on request, from the mailbox settings page (#1496)
* feat(receipt-hunt): a button that looks for receipts on request

The nightly cron exists but still does not search mailboxes, and for a
good reason: a sweep of one real 172-message mailbox took over 600s,
against a scheduled function's 300. Pressing a button is the honest shape
for work that big. A bounded pass reports what it found and how much is
left, and the person decides whether to press again; a nightly run could
only truncate silently.

POST /api/receipt-hunt/run searches the mailboxes for eight purchases and
fetches at most ten receipts per press. Gated on the AI tier, because
reading the amount out of a PDF is what makes a fetched attachment
matchable at all: without it the hunt would file documents that can never
pair, which is worse than not running. Writes no journal entries; every
pairing is still a proposal waiting for approval.

The button lives on the mailbox settings page, which already ships, and
says what happened in words rather than a spinner that stops: "3 underlag
hämtade. 12 köp kvar att söka igenom."

huntCompany gains maxReceipts so a manual pass can carry a different
budget from a nightly one.

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

* fix(receipt-hunt): stop a manual press spending its budget on the wrong purchases

The first real press searched eight purchases, read forty mails and found
nothing, which looked like a broken model. It was the ordering.

Purchases are searched largest first, which is right for a nightly sweep
that eventually covers everything and wrong for a button pressed a few
times. On a real ledger the largest rows are the least likely to have a
findable receipt: rent already invoiced, bare payment references, direct
debits. Those filled the forty-mail cap, so the productive purchases
further down the list, the ones whose receipts are actually sitting in the
mailbox, were never read at all.

The cap was the binding constraint, not the time: eight purchases and
forty mails took 43s of the 300 available. A press now searches 25
purchases and reads 100 mails, measured at 85s and finding 7 underlag on
the same ledger that returned 0 before.

huntCompany gains maxMails alongside maxReceipts, so a manual pass can
carry a different budget from a nightly one rather than sharing an
environment default with it.

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

* fix(receipt-hunt): one underlag per purchase, and a press that fits its budget

Your second press exposed three things, none of which a dry run reaches.

It took 5.8 minutes. The 85s I measured was a dry run, which never
fetches, uploads or extracts; each fetched receipt costs about another
37s because it is downloaded, stored, and then read by a model that opens
the PDF. Seven of them ran past the 300s a serverless function gets, so
in production that press would have been killed. Four receipts per press
keeps a full pass inside the budget.

It fetched seven receipts and proposed nothing. A single mail carries the
invoice AND the receipt for one purchase under different names
("Invoice-E19DBF63-0021.pdf" beside "Receipt-2066-0204-8388.pdf"), and
the same receipt reaches a second mailbox on a different message. Each
was fetched separately, so the pool filled with identical candidates and
the matcher refused to propose any of them rather than flip a coin. The
per-run key is now the vendor and the total, which is what identifies a
purchase; the filename only decides when no amount was read. Nine
duplicates already in the pool were removed.

And with the duplicates gone it still proposed nothing, for a separate
reason: "Utlägg Norwegian" scored 0.18 against "Norwegian Air Shuttle
AOC AS". Utlägg is Swedish for an expense reimbursement, bank vocabulary
rather than a company, and leaving it in broke the token-subset match, so
an exact 1 998 kr pair leaned entirely on a date eight days out and fell
under the floor. Stripped, along with överföring, via internet, bg-bet
and autogiro, in the comparison path only.

normalizeMerchantName is untouched: it is the persisted konteringskarta
key with a SQL mirror, and its 22 string pins and the 27-pair golden set
still pass.

Measured after: the Norwegian pair proposes at 0.72.

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

* perf(mail): read each message once per press, not once per query

The third press proposed a pairing, which the second had not, but still
ran 5.2 minutes against a function's 300s. Cutting receipts from seven to
four had only saved 36s, which said the receipts were never the cost.

Every search fetched a full message for every hit, and a press searches
many purchases across every connected mailbox. One receipt mail answers
several of those queries, so 25 purchases against 2 mailboxes could ask
Gmail for well over a thousand messages to end up with a hundred distinct
ones. Deduplication happened in the caller, too late to save the work.

A mail's content never changes, so it is now read once per mailbox and
kept, bounded at a thousand entries and evicting oldest first. Measured
on the same ledger: 55 purchases and 100 mails now take 102s, where 25
purchases alone previously cost around 264s before a single receipt was
fetched.

clearMessageCache exists because tests reuse message ids and production
does not.

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

* fix(receipt-hunt): do not refetch a receipt the company already holds

The fourth press fetched four documents the company already had:
Bolagsverket, Supabase twice, Uber. They came back because I had deleted
them as duplicates, and the cross-run check is the message and attachment
id, which lives in the rows I removed.

That was my mistake, but it exposed a real gap. The vendor-and-total key
only deduplicates inside a single pass. Across passes the same purchase
still arrives as an invoice in one mail and a receipt in another, with
different file keys, and both were fetched: the pool fills with identical
candidates and the matcher then refuses to choose between them, which is
how a press can fetch four documents and propose nothing.

The pass now starts from what the company already holds, so its budget
goes on documents that are actually missing.

Receipts per press drops to three. Measured on this ledger, a fetched
receipt costs about 50s from download to a stored amount, and that is the
model reading the PDF rather than the network: seven took 5.8 minutes and
four took 5.1, both past the 300s a function gets. Three fits, but it is a
stopgap. Doing the fetch inside the request is the wrong shape for work
this slow, and the fix is to move it off the request rather than keep
shaving this number.

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

* fix(mail): a mail body must not outlive the run that read it

Two findings from the review, both correct.

The message cache held whole MailCandidate values, and one of those fields
is the mail body. The contract says a body is read once to extract fields
and discarded, and a process-global cache quietly broke that: bodies of
one company's mail sat in memory across requests until eviction or a
restart. The MailSearchService contract now has releaseCache, the Gmail
adapter clears its messages, and the hunt calls it in a finally so a
failed run releases them too.

The duplicate key accepted an empty vendor, so two unrelated documents
that happened to cost the same collapsed into one candidate. Those now
fall back to the file they came from: without a vendor there is nothing
to anchor an amount to.

The same finding caught something worse that I had introduced one commit
earlier. The persistent check derived its key from the stored extraction
while the fetch derived one from the reading model, so a document filed
as "Norwegian Air Shuttle AOC AS" did not recognise an incoming
"Norwegian" and was fetched again. Rather than guess at aliases, which
would fold "Google Cloud" into "Google Workspace", the identity is now
written onto the row when the receipt is filed and read back verbatim.
Rows filed before that fall back to the extraction.

receiptIdentity is one exported helper with its own tests, used by both
sides, instead of the same expression written twice.

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

* fix(receipt-hunt): a monthly subscription is not a duplicate of last month

The review caught that my duplicate key was worse than the problem it
solved. Anthropic bills the same amount every month, and keying on vendor
and total alone made July look like a duplicate of June: every later
receipt from any recurring supplier would have been suppressed forever,
silently. Duplicates block one proposal; that would have lost a receipt
per month per subscription.

The identity now carries the document date. Two documents for one
purchase share a date; June and July do not.

Two smaller faults in the same key. The amount was serialised as a raw
float, so 0.1 + 0.2 read as a different total from 0.3; it is rounded to
öre like every other money comparison in this codebase. And a document
with no vendor was identified by its filename alone, which collapses two
unrelated papers whenever a billing system attaches "invoice.pdf": those
now carry the message they came from.

The key is versioned so a future change to its shape cannot be mistaken
for a match against rows written under the old one.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:08:06 +02:00
38f5d9812e feat(receipt-hunt): find receipts in connected mailboxes and pair them on the amount (#1492)
* feat(receipt-hunt): nightly matcher pairing unbooked purchases with held receipts

Stages an attach_document_to_transaction proposal for every unbooked card
purchase whose receipt the company already holds, so the underlag is attached
before the transaction is booked and the gap never forms. When the user later
books it, categorize-core.ts propagates the document onto the new verifikat
through the matched_transaction_id link the executor writes.

Deliberately scoped to UNBOOKED transactions. The posted-verifikat backlog is
96% imported history whose originals live in the previous system, so it stays a
pull (the verifikat_missing_document worklist) rather than a nightly push.

Ranking reuses scoreUnderlagCandidates; the pool is loaded once per company
instead of per transaction, which removes both the N+1 and the newest-50
truncation a per-transaction lookup imposes on a deep backlog.

Five guards, each mutation-tested: a confidence floor above the shared
candidate floor, an ambiguity margin so two equally-good receipts are left to
the picker rather than coin-flipped, one-receipt-one-purchase, one live
proposal per purchase, and permanent suppression of pairs a human rejected.
Suppression is derived from pending_operations history rather than a new table:
terminal rows are immutable and a rejection is already the durable "no".

Runs 05:30 UTC, after the 05:00 bank sync. Gated on RECEIPT_HUNT_COMPANY_IDS,
which hunts nobody when unset so enabling it stays a deliberate act. No
migration, no journal writes, no UI: proposals land in the existing Granskning
queue.

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

* feat(receipt-hunt): dry-run mode for provkörning against a real ledger

Returns the pairings a run would stage without writing any of them, so a
company can see tonight's proposals before they reach the granskningskö and so
the matcher can be validated against production data without staging an
operation.

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

* fix(matching): fold Swedish bank descriptors so receipts reach their purchases

calculateMerchantSimilarity compared raw bank descriptors, so a receipt from
"Alviks kött och fisk" scored 0.125 against the bank's own row for it,
"Alviks koett och fisk K3667 Kortköp/uttag" — an öre-exact pair no threshold
could reach. Adds normalizeForMatch, used for similarity only, which folds what
the card rails add and never changes identity: the K#### token, Kortköp/uttag
verbs, a leading "Kortköp YYMMDD", trailing /YY-MM-DD dates, reference numbers
glued to the name, domain wrappers, legal forms, and the three ways banks mangle
Swedish letters (ö, transliterated "oe", and ?? mojibake). Processor markers
become spaces because the merchant sits before the star in GOOGLE*PLAY and after
it in K*IKEA GALLE. Token-subset containment is scored level with substring
containment so a receipt's legal name matches the bank's trading name.

normalizeMerchantName is left byte-identical and now documents why: it is a
transitive input to categorization_templates.counterparty_name, a persisted
UNIQUE key with a hand-written SQL mirror the ledger-context RPC recomputes at
query time. Changing it would make stored keys stop equalling computed ones, so
the konteringskarta join misses and insertOrUpdateTemplate inserts a second row
per merchant instead of migrating the occurrence counts.

Aggressive folding is safe because it is applied to both sides of every
comparison, so an over-eager fold still matches; the risk is collision between
different merchants, which the new tests guard.

Measured on 27 receipt/transaction pairs humans actually confirmed in
production: recall 27/27, and 0/7 false positives on deliberately similar but
distinct merchants. Full unit suite unchanged (13,004 passing), including the 22
string pins on the frozen key path.

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

* feat(mail): read-only Gmail connector so receipts are found without forwarding

Forwarding was the only way a receipt reached Accounted, and it is both
unpopular (97% of companies with the problem have never used their inbox
address) and fragile: Arcim's own forward has been off for weeks and nobody
noticed. This lets the hunt look in the mailbox instead.

Scope is gmail.readonly and nothing else. It can search and download attachment
bytes, and it structurally cannot send, modify or delete: the promise the
consent screen makes is enforced by the grant, not by our code being careful.
The consequence is deliberate: the agent can prepare a forward for a portal-link
receipt but can never send one itself.

Query-then-classify, never sync. For each unexplained purchase we run a
provider-side search in a -3/+10 day window, pull metadata for a handful of
hits, and keep nothing. No mailbox is mirrored and no message body is stored,
which is what keeps this inside Google's Limited Use terms and GDPR data
minimisation. Mail is searched only for purchases Underlag could not already
explain, so a receipt we already hold never costs a mailbox read.

The query ORs merchant against amount rather than requiring both: demanding both
misses every rebrand and reseller (Anthropic bills as Claude), while the amount
alone is a strong filter inside two weeks.

mail_connections is service-role only with RLS enabled and zero policies,
because the row holds a live refresh token and RLS cannot hide a column.
Uniqueness is (company, provider, address) so a second mailbox is additive and a
reconnect updates in place. Tokens are AES-256-GCM under their own key by
preference, since a mail grant reads correspondence rather than backups.

Core reaches the extension through a registered service, mirroring
lib/email/service.ts, so lib/receipt-hunt never imports from @/extensions and a
zero-extension build still compiles.

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

* feat(mail): connect UI and ingest, making the hunt reach into the mailbox

Two halves that together make the connector usable.

Ingest (lib/receipt-hunt/ingest.ts, core): fetches the attachment, files it as a
document and an inbox item with source 'mail_hunt', then stages the pairing.
It lives in core because it writes documents and inbox items, and an extension
may never import another extension; the mail extension only ever hands over
bytes.

No re-matching for a hunted receipt: it was fetched WHILE SEARCHING for a
specific purchase, so the pairing is known by construction. The search is a
deliberately broad OR query, which is exactly why the proposal still goes to a
human with the mailbox, sender and subject written on it rather than being
linked automatically.

Provenance goes in channel_context, never extracted_data, because retrying
extraction overwrites extracted_data wholesale and the record of which mailbox
a receipt came from has to survive that. A partial unique index on
(company_id, channel_context->>'mail_message_id') makes re-runs and the same
receipt arriving in two mailboxes idempotent, and a 23505 is treated as success
rather than an error.

Guards, both mutation-tested: a duplicate message costs no provider call, and an
oversized attachment is skipped rather than stored. One unreadable attachment
falls through to the next and never aborts a night's hunt.

UI: /settings/mail lists connected mailboxes with their health, connects a new
one through a user-gesture tab (opened before the await, so popup blockers do
not eat it), and disconnects behind a ConfirmDialog that states the outcome up
front, including that already-approved receipts stay because they belong to the
bookkeeping now. Strings in sv and en; the read-only promise is spelled out on
the page rather than buried in a consent screen.

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

* fix(mail): renumber migrations to clear a version collision on main

20260806150000 was already taken by preserve_preset_committed_at, and
woocommerce_connections plus enforce_balance_on_posted_insert landed after this
branch was cut. Two files sharing a version breaks every fresh database, which
only shows up on a clean setup rather than on an already-migrated one.

Applied to prod under the new versions (20260807090000 / 20260807090100), so
schema_migrations matches these filenames exactly.

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

* fix(receipt-hunt): make the mailbox search actually able to find an underlag

A provkörning against a real ledger returned the same seven unrelated
messages for every purchase, all reporting no attachments. Three separate
causes, each fixed and pinned:

1. `getMessageSummary` asked Gmail for `format=metadata`, which returns
   headers and omits `payload.parts` entirely. Every message therefore
   looked attachment-free, `bodyIsReceipt` was always true, and the
   `found.find(c => c.attachmentIds.length > 0)` guard in the hunt could
   never select anything: the feature could not file a single receipt.
   Gmail has no format that returns MIME structure without the body, so
   the body now comes down the wire; it is read for nothing and stored
   nowhere.

2. The bank's description is not a merchant name. "Lön Juli Jakob
   Överföring via internet" searched for "Juli" and matched most of the
   mailbox. Month names and payment-rail boilerplate are now stopwords.

3. Salary and tax runs are a company's largest outgoing rows, so they
   consumed the whole search budget hunting receipts that cannot exist.
   `canHaveEmailReceipt` skips them for the mail leg only. Deliberately
   narrow: a supplier invoice paid over bankgiro does arrive by mail, and
   an "Utlägg" reimbursement has a real receipt behind it.

Measured on the same ledger: 22 hits, 0 with attachments, 0 ingestable
-> 4 hits, all with attachments, 3 of 4 correct (Elgiganten, Sting,
Anthropic). The fourth matched a Stockholm billing address, which is why
every proposal still waits for a human.

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

* feat(receipt-hunt): let a model resolve merchants and pick the receipt

The keyword hunt was failing for reasons regex tuning cannot reach, all
measured against a real mailbox rather than assumed:

- `from:anthropic.com` returns 0. Receipts arrive here by being
  forwarded, so the sender is the user, not the vendor.
- The exact charged amount returns 0. The bank posts a converted SEK
  figure that appears nowhere in a USD receipt.
- A date window around the purchase returns 0, while the same merchant
  search without one returns 10+. A forward is stamped when it was
  forwarded, sometimes months later.

So the query now searches merchant names across the whole mailbox, and
precision is restored by judgement rather than by syntax. Two model calls
per run, both through forced tool use so the reply is a shape and not
prose to be parsed:

1. `planMerchantGroups` resolves bank descriptors to merchants and merges
   repeats. Six Anthropic subscriptions become one search and one
   decision instead of six of each.
2. `assignReceipts` decides which mail, and which attachment on it, is
   the receipt for which charge, and says why in a sentence the reviewer
   reads.

The attachment, not the message, is the unit of an underlag: a single
forward routinely carries receipts for several purchases ("Fwd: Kvitton
februari" has five). Migration 20260807103000 moves the dedupe key from
message to message+attachment, with a backfill, because the old index
would have silently blocked every receipt after the first in a forward.

The model may not produce any number that reaches the ledger. It returns
ids, a confidence and a reason; amounts, dates and the write stay in
deterministic code. Its answer is validated, not trusted: an unknown
message id, an invented filename or a low confidence drops the pairing,
and any failed call proposes nothing at all. Every result still waits
for a human.

Measured on the same ledger: 0 receipts that could ever be filed -> 3
correct pairings (Elgiganten, Sting office invoice, Anthropic), each
with a stated reason. The five remaining Anthropic charges are dated
after 2026-06-15, when forwarding to the connected mailbox stopped; the
model declined them correctly.

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

* refactor(receipt-hunt): amount first, and drop the confidence scoring

Three findings from how others build this, applied.

Production email search (Superhuman, Haystack 2026) reports that recall
comes from loosening retrieval and letting the model filter downstream,
not from tightening the query. Retrieval depth per merchant 12 -> 25, and
purchases the planner cannot name a merchant for are now searched by
amount alone instead of skipped: a line like "1260525758758
Europabetalning" identifies no merchant but is a real supplier payment
whose invoice may carry exactly that total.

Reconciliation engines weight amount far above date (Midday: 35% vs 5%)
because banks post late while amounts do not drift. The Gmail query now
leads with the amount and ORs the merchant, rather than dropping the
amount whenever a merchant alias exists. Still an OR: a receipt billed in
USD never contains the SEK figure the bank charged.

The confidence score is gone entirely. Research on verbalised confidence
finds it badly calibrated, clustered on round-number anchors and barely
better than chance at separating a model's own right answers from its
wrong ones. That matched what this ran into: the model anchored on 0.6 /
0.7 / 0.75 / 0.9, and the 0.7 threshold discarded two correct pairings.
It is replaced by an observation rather than a self-assessment, whether
the charged amount is actually visible in the mail, which is what a
reviewer checks first and what sorts the queue.

Also fixes a real defect the run exposed: the one-file-one-purchase guard
only held within a merchant group, so when the planner split one landlord
into "Sting" and "Kontorsplatser" both 15 000 kr charges were assigned the
same invoice. A file is now claimed once per run, which is the duplicate
underlag BFL forbids.

Measured on the same ledger: 3 -> 5 pairings, no duplicate.

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

* refactor(receipt-hunt): harvest receipts, then pair them on the amount

Splits the mailbox leg in two along the line of what each side can
actually know.

The model was being asked which purchase a mail belonged to. Deciding
that needs the amount; the amount lives inside the PDF; a Gmail preview
essentially never shows it. Measured over a real mailbox, every single
pairing came back "belopp ej synligt": it was answering without the
deciding evidence, which is why it declined five of six repeat
subscriptions and why two correct pairings sat just under a threshold.

Now it answers only what a subject, a sender and a preview line support:
is this mail an underlag, and which attachment is it. Then the receipt is
fetched, the extraction that already runs on document.uploaded reads its
amount, date and vendor, and the pairing is the same deterministic
amount-and-merchant match every other underlag goes through. Amount
becomes decisive for real rather than as an instruction the model could
not act on.

The load-bearing fix is small: ingest now copies the extraction result
onto the inbox item. The pool is read from invoice_inbox_items, so a
hunted receipt with no extracted_data could never have matched anything,
and the whole mail leg was quietly incapable of producing a pairing on
amount.

Consequences, all deliberate:
- Harvesting runs BEFORE the pool is read, so a receipt found tonight is
  paired tonight rather than a night later.
- One staging path instead of two. Mail-sourced proposals carry the same
  preview and confidence as every other, plus where they came from.
- Deduped on the attachment filename, not on the message: the same
  invoice arrives as an original, a reminder and two forwards, and the
  old key filed "Invoice_13041840.pdf" four times over.
- Capped at 8 receipts per merchant per run.

Measured on the same ledger: 5 pairings attempted from thin evidence ->
16 real documents identified, each waiting on an amount it can be checked
against.

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

* refactor(receipt-hunt): the model reads mail, arithmetic does the matching

Collapses the mailbox leg to one model call that extracts fields, and
hands every judgement back to deterministic code.

Gone: resolving bank descriptors to merchant names, deciding which mail
belongs to which charge, and the confidence score gating the result.
Three prompts and two model calls become one, and mail-intelligence.ts
drops from 450 lines to 250.

What made this possible was measuring what a mail actually contains. The
body was being downloaded and thrown away in favour of a 200-character
snippet, and the body is where a forwarded receipt quotes its original
sender and its original date. That is the purchase date, the thing whose
absence forced the date window off entirely and made the old design miss
five of six repeat subscriptions. It was there all along.

So the model now answers only what text can support: is this an underlag,
from whom, when, and for how much if the mail says so. Fields, not
judgements. Everything after is arithmetic:

- Retrieval is deterministic. No model decides what to search for.
- Fetching is gated by worthFetching(): a stated amount is enough on its
  own, a vendor needs a plausible date, and a mail found by a purchase's
  own search is evidence in itself. That last rule is what handles a
  supplier the bank and the invoice name differently ("Kontorsplatser j
  BG" against "Stockholm Innovation & Growth AB"), which is what the
  deleted merchant-resolution call used to buy.
- The pairing is the existing scorer, reached the same way as every other
  underlag: fetch, let the extraction that already runs on upload read
  the PDF, match on the amount. Amount is decisive in fact rather than as
  an instruction the model could not act on.

Also adds the Swedish thousands-space amount formats to the query.
Measured: the Sting invoice is findable as "15 000,00" and "15 000" and
by no ungrouped form at all, so every amount search was missing them.

Measured on the same ledger: 5 thin pairings -> 8 real documents, each
with a vendor and a true purchase date, waiting on the amount in its own
PDF. Currency is never converted to make a number agree.

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

* fix(receipt-hunt): trust the bytes, not the mail, when filing an attachment

Found by the first live run, which fetched nothing and reported success.
Three defects, each invisible to a dry run because a dry run never
downloads anything.

1. Gmail declares a forwarded PDF as application/octet-stream, and
   uploadDocument validates content against the declared type, so the
   upload was rejected: "Filinnehållet matchar inte den angivna
   filtypen". Every forwarded receipt with a generic MIME type would
   have failed this way, silently, since ingest swallows one bad
   attachment to protect the rest of the run. The type is now sniffed
   from the magic bytes, then the filename, and only then from what the
   mail claimed.

2. The filename was re-derived by a second full message fetch inside
   fetchAttachment, which came back empty and fell back to a generic
   "underlag.pdf", discarding the real "2332687551.pdf" the search had
   already reported. The known name now wins.

3. The provkörning script imported lib/init instead of calling
   ensureInitialized(), so document.uploaded reached no handler and
   nothing was ever extracted. It also used static imports, which are
   hoisted and ran before .env.local was read, leaving the extraction
   extension unable to build a Supabase client. Both are script defects,
   not product defects: the cron route calls ensureInitialized() at
   module level as the architecture requires. The script now loads the
   environment first and imports dynamically.

Also makes the per-run fetch cap tunable (RECEIPT_HUNT_MAX_RECEIPTS) so a
pilot can be held to a couple of documents, and adds --live to the
script, which is the only way it writes anything.

Verified end to end against a real ledger, every link exercised for the
first time: two attachments fetched from Gmail, stored with their real
names and types, extraction run on both, the amount copied onto the inbox
item, and the deterministic matcher pairing Elgiganten 21 639,00 kr from
the PDF against the -21 639 kr card purchase at 0.85, staged into
Granskning as attach_document_to_transaction. The second document, a
Bolagsverket filing receipt, carries no total and correctly paired with
nothing.

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

* feat(receipt-hunt): sweep a whole mailbox, and stop lending one receipt twice

A backfill on a real ledger, 22 documents fetched from 172 messages.

Batches the extraction (25 mails per call) so a first run on an existing
company can read the whole mailbox instead of the 40 mails one call can
carry, and makes the per-run caps tunable
(RECEIPT_HUNT_MAX_MAILS, RECEIPT_HUNT_MAX_RECEIPTS) so a pilot can be
bounded. The nightly caps stay where they are: they pace the review
queue, and a backlog is a different job from a nightly tick.

Two defects the backfill exposed, neither reachable from a dry run:

The one-receipt-one-purchase rule only held inside a single run.
`spentDocumentIds` is per-invocation, so an H&M receipt was proposed
against a -358 kr purchase on one pass and a -354 kr purchase on the
next, and approving both would have put the same underlag on two
verifikat. A live proposal now claims its document across runs, the same
way it already claimed its transaction.

A document reported with no filename, on a message carrying five
attachments, was not an answer but a shrug: the caller fetched
attachment number one and hoped. Those are dropped now. A body-only
receipt, where there is nothing to choose between, still passes.

Measured after the sweep: 21 of 22 documents read correctly, and the
binding constraint on this ledger is no longer retrieval but currency.
Ten receipts are in SEK and five of those pair on the amount; twelve are
in USD or EUR, where the bank charged a converted figure that appears
nowhere in the receipt, so no comparison is possible and none is
attempted.

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

* feat(mail): show the provider's own mark on the mailbox settings page

Someone connecting a mailbox is picking an account at a provider, and the
provider's mark is how they recognise which one. A generic envelope
glyph said "mail" when the question is "whose".

The Google "G" already existed, drawn inline inside GoogleAuthButton for
the sign-in flow. It moves to components/ui/provider-marks so there is
one definition rather than two, and a Microsoft square joins it for the
Graph connector. Both stay inline: no external host is contacted for an
icon before anyone has agreed to anything.

These are the only coloured glyphs in an achromatic interface, which is
deliberate rather than an oversight. A brand mark is identity, not
chrome, and Google's terms require its mark unaltered rather than tinted
to match a palette.

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

* fix(archive): drop the duplicate mail_connections exclusion left by the rebase

Main added the table to ARCHIVE_EXCLUDED_TABLES while this branch was
open, so rebasing produced the key twice and the zero-extension build
failed to type check. Main's entry stays, in its alphabetical place, and
keeps the sentence that answers the retention question: the grants are
not räkenskapsinformation, but the receipts they find are archived as
documents.

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

* fix(mail): record who disconnected a mailbox, without keeping the token

Raised by the compliance review: disconnect() hard-deleted the row with
no trace, and which mailboxes feed underlag into the books is a control
over how räkenskapsinformation is produced (BFNAR 2013:2 kap 8), so
switching one off should be reconstructable years later.

Written by hand rather than by the write_audit_log trigger the accounting
tables use. That trigger copies the whole row into audit_log, which here
would mean copying an encrypted refresh token into a second table and
keeping it after the entire point of the delete was to destroy it. The
sibling credential table shopify_connections omits the trigger for the
same reason. Only the address and provider are recorded, pinned by a test
that fails if a credential ever reaches the audit entry.

The review's two other flags were checked rather than assumed: nothing
purges mail_hunt documents, and categorize-core.ts:403 does carry the
attached document onto the verifikat when the transaction is booked.

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

* fix(mail): bound every outbound call, and stop the token widening itself

Four findings from the review, each checked against the code first.

Neither the Gmail API nor Google's token endpoint had a deadline. Both
are awaited inside Promise.all across mailboxes, so one stalled request
held the whole company's hunt open until the platform killed the run.
Both now carry a 15s AbortSignal, which turns a stall into one mailbox
missing from tonight's sweep.

`include_granted_scopes: 'true'` let Google fold scopes this app was
granted elsewhere into the token issued for a mailbox, so a grant could
carry more authority than the consent screen showed. Removed, and pinned
by a test asserting the parameter is absent.

disconnect() ignored both statement results: a failed delete still wrote
an audit entry claiming the mailbox was disconnected while the credential
was live, and a failed audit insert passed silently. The delete now
throws, so the entry is never written for a delete that did not happen.
The audit failure is logged rather than rolled back: the two can now only
diverge one way, credential gone and note missing, and recreating a
credential to keep them in step would be worse than a missing note.

The fifth finding is real and stays open by choice, recorded in
DECISIONS.md: the cron still passes searchMail=false. A sweep of one
172-message mailbox took over 600s against a maxDuration of 300, so
enabling the mailbox leg nightly would time out mid-run. That flag and
RECEIPT_HUNT_COMPANY_IDS get flipped together once the per-company budget
is measured.

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

* fix(receipt-hunt): file each attachment under its own identity

Four more findings from the review. The first is a real defect.

ingestMailCandidate loops over candidate.attachmentIds, but the dedupe
key, the mail_attachment_id provenance and the filename were all read
from index 0. Storing the second attachment therefore recorded the
first one's key and name, which mislabels the row and, because the key
is unique, permanently blocks the first attachment from ever landing.
Masked today only because the hunt narrows to a single attachment before
calling in, so nothing in the current path exercises it. All three now
come from the attachment actually being stored, and the duplicate
pre-check moved inside the loop so trying a second attachment is not
suppressed by the first already being filed. Mutation-tested.

The per-run fetch key was the bare filename, which is not an identity:
"invoice.pdf" is what half the world's billing systems attach, so a
second supplier's invoice would be dropped as a duplicate of the first.
Scoped by vendor as well, keeping the behaviour it was written for, one
fetch for an invoice that arrives as an original, a reminder and two
forwards.

Adds tests/pg/mail-hunt-file-dedupe.pg.test.ts for the new unique index:
five attachments from one forward all land, the same attachment is
refused twice, two companies hold the same file independently, other
inbox sources are untouched by the partial predicate, and the
message-scoped predecessor is gone. Written against CI's Postgres; there
is no local DATABASE_URL here, so CI is what exercises it.

--live now refuses unless RECEIPT_HUNT_CONFIRM names the same company.
The script writes to whatever .env.local points at, which for this repo
is production, and a recalled command should not be able to fire it.

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

* fix(test): cast the jsonb parameter so Postgres can type it

pg-real could not determine the type of $3 inside jsonb_build_object.
An explicit ::text is what the other pg tests do.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:23:42 +02:00
MattssonandClaude Fable 5 6aab1fbe23 fix(transactions): fetch all pending rows so the inbox is never capped at 200 (#1493)
* fix(transactions): fetch all pending rows so the inbox is never capped at 200

The transactions page fetched only the newest 200 rows and derived the
inbox from that window, with no load-more in inbox mode. Users with more
than 200 transactions since their oldest unhandled row had older pending
transactions silently hidden, and the footer counter understated the
real backlog (confirmed live: 188 shown vs 286 actual).

The inbox now merges a fetchAllRows query for every pending row
(is_business null, not ignored) into the transactions state, so all
existing row-mutation paths keep working unchanged. The history view
keeps its contiguous newest-first paging via a tracked window boundary
(pagedCountRef / pagedThroughDate) and hides the sparse older pending
rows that would otherwise read as missing bookkeeping.

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

* fix(transactions): address review findings on the pending-backlog fetch

Stable (date, id) ordering on both history page queries so offset paging
cannot skip or repeat same-date rows; surface a pending-backlog fetch
failure through the existing load-failed toast instead of silently
rendering a complete-looking inbox; chunk fetchPotentialMatches .in()
lists at 150 ids so a large pending backlog cannot exceed PostgREST URL
limits and silently drop match hints.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:45:21 +02:00
MattssonandClaude Fable 5 d4b0bd3df1 feat(invoices): expose the automatic reminder kill switch in settings (#1476)
* feat(invoices): expose the automatic reminder kill switch in settings

The send_invoice_reminders column, API schema, and cron processor check
already existed, but no UI ever exposed the toggle. Add a switch in
Settings -> Fakturering (day thresholds fold away when off, values
preserved), and make the invoice detail Paminnelser card say reminders
are off instead of promising emails that will never be sent.

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

* fix(invoices): do not assume reminders enabled before settings load

CodeRabbit finding on PR 1476: with no company_settings row loaded, the
Paminnelser card defaulted to promising the reminder schedule. Track the
toggle as boolean | null and render no schedule text until the settings
row has actually resolved.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:24:15 +02:00
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>
2026-08-09 20:52:25 +02:00
170b2722d6 feat(import): the Din historia reveal after SIE import (#1473)
* feat(import): the Din historia reveal after a successful SIE import

Fourth slice of the activation concept, stacked on the theater (#1471).
When the theater ran, the result step's success header becomes the
reveal: the settled constellation beside the personalized story
({years} år av historia, verifikat/konton/motparter, the balance
tie-out) and the bank bridge ("Historiken är på plats. Det som saknas
är nuet: banken.") deep-linking into the bank connect flow.

Honesty guards from the adversarial pass: the reveal requires actually
imported entries (an opening-balances-only run keeps the plain header),
the balance claim is suppressed when unbalanced vouchers were skipped,
and sandbox hides the bank bridge (live connections are stripped from
/import there). Failures and theater-less successes render the exact
previous header + stats grid.

The canvas is extracted to a shared TheaterCanvas (build mode driven by
the narration timeline via an imperative handle; settled mode for the
reveal: everything born, camera home, breathing only).

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

* fix(import): tame the constellation labels against real-world data

Real BAS names are paragraph-length and real files cluster in one class
region, which piled fifteen full labels into mush (founder screenshot,
Arcim Technology import). Labels now truncate hard (24/16 chars), only
the five heaviest accounts and counterparties carry labels, spread and
radius widen within a bucket, and a greedy per-frame collision pass
skips any label that would overlap one already drawn (importance order:
hub, buckets, heaviest first). The reveal also drops its meta line:
less text, the story is the headline + stats + bridge.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 20:05:00 +02:00