Commit Graph

145 Commits

Author SHA1 Message Date
Mattsson 3ec76d39db fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
2026-08-18 23:00:44 +02:00
Jakob Wennberg 93541d7186 fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges (#1633)
* fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges

Follow-up batch to #1629: the six documented deferred items from
dev_docs/loading_states_analysis.md, in the same vocabulary (first-load-only
takeovers, background reconcile behind mounted content, row/button-level
pending, sequence guards).

- Invoice detail pages: kundfaktura and leverantorsfaktura detail no longer
  blank the whole page for one-field changes. fetchInvoice shows the blocking
  spinner/skeleton only before the first paint (or when the pager steps to a
  different invoice); Bokfor / status / finalize / payment / send / Attestera /
  Markera betald / kreditera refetch behind the mounted page, the acting
  button shows a spinner-in-button, and the handlers await the refetch so
  pending covers until the content reflects the new state. The supplier
  detail's single isProcessing boolean became processingAction so the spinner
  lands on the clicked button only. (The leverantorsfakturor LIST
  try/catch/res.ok item was already fixed by #1629.)

- useDestructiveConfirm: confirm(opts, action?) can now carry the destructive
  operation, so the dialog's existing isLoading spinner actually shows while
  it runs, dismissal is blocked meanwhile, and confirm resolves false if the
  action throws. Adopted at the /transactions row delete and the supplier-
  invoice detail delete (which previously permitted duplicate DELETEs with
  zero feedback).

- Batch parallelization: new lib/concurrency.ts mapWithConcurrency (bounded
  worker pool, order-preserving, tested). /transactions batch categorize /
  ignore / delete run per-row requests 5 at a time instead of strictly
  sequentially; the bulkbar counter ticks per completed row.

- Toast-spam reduction: batch categorize rows run silent (exit animation,
  count decrement and state patch stay; no per-row Bokford or generic failure
  toast) and ONE aggregate toast reports "N bokforda[, M misslyckades]" with
  a single Angra alla action that pools the same /uncategorize endpoint over
  every booked row (per-row undo is feasible today, so the aggregate is too).
  Interactive escalations (SI/CI match suggestions, duplicate warning,
  activate-account) deliberately keep their dialogs.

- Underlag row-click flash: InvoiceInboxWorkspace handleSelect seeds the
  detail pane synchronously from the clicked list row and starts the document
  load in parallel with the detail GET (which hydrates on arrival), so a row
  click never flashes the onboarding/empty state, and a stale-response guard
  keeps a slow fetch from overwriting a newer selection.

- #1629 round-2 edges: /pending holds the loading state when a fetch for a
  not-yet-loaded tab FAILS (never renders the previous tab's rows under the
  new tab's header, and never fakes an empty state); /transactions clears
  transactions/skvRows (+ count/paging) and bumps both fetch sequences on
  company switch, and loadSkvRows got the same sequence-guard pattern as
  fetchTransactions.

Gates: full vitest suite green (14772 passed), tsc byte-identical to the
origin/main baseline (stash-diffed), eslint 0 errors on touched files
(warnings identical to baseline), check:guards green, package-lock untouched.

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

* fix(ui): harden action feedback against stale responses and failures

Address the seven CodeRabbit findings on #1633:

- invoices/[id] + supplier-invoices/[id]: latest-request guard in
  fetchInvoice (sequence token) so a mutation refresh overlapping pager
  navigation can never commit invoice A's state under invoice B's URL;
  the deferred related-document writes are guarded too
- supplier-invoices/[id]: try/catch/finally in approve/book/mark-paid/
  credit/uncredit so a rejected fetch()/json() clears processingAction
  instead of leaving every invoice action disabled until reload
- transactions: extend the skattekonto sequence guard to the
  connection-status write so a status response started under the
  previous company cannot flip the reconnect banner for the new one
- transactions: runCategorize resolves { ok, journalEntryId } so the
  batch aggregate counts a 200-with-null-journal-entry booking (flag
  flip) as success instead of narrating it as misslyckades; Angra alla
  only targets rows with an actual verifikat, since the storno endpoint
  rejects rows without one
- transactions: shared undoneIdsRef lets "Angra alla" cancel a pending
  finishBooking state patch; a fresh booking clears its row's entry so
  re-booked rows still get their delayed patch
- InvoiceInboxWorkspace: monotonic request tokens for the detail and
  document reads so a same-item reload cannot resolve out of order and
  paint a stale snapshot or document URL
- messages: ICU plural for the success part of both partial batch
  descriptions in sv and en (1 bokford, not 1 bokforda)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:30:25 +02:00
Mattsson f8db38f989 fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask

Invert PostHog session-replay masking from visible-by-default with pattern
masking to deny-by-default: every input value is masked wholesale (rrweb
maskAllInputs, no maskInputFn) and every text node is masked unless it sits
under data-ph-unmask chrome or a table column header (th). Chrome tags live
on the shared UI primitives (PageHeader, Label, Button except combobox
triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help
popovers, empty states, settings labels), and tagged chrome is still
pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask
beats data-ph-unmask, so call sites that interpolate user data into chrome
stay masked; a very-thorough audit swept every unmasked primitive and each
found site got a call-site mask. Confirm-dialog wrappers and toasts stay
masked centrally: their copy describes user objects by design. Untagged new
UI over-masks instead of leaking. Privacy policy, RoPA and decision log
updated in the same change.

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

* fix(analytics): tag detail-section chrome merged from main

The register-detail primitives landed on main after the replay-masking
audit ran: kickers and DefRow labels are static i18n chrome, values stay
masked.

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

* fix(analytics): close skeptic and review findings on replay masking

Explicit data-ph tags now resolve before the th chrome fallback, so a th
nested inside a data-ph-mask container masks correctly (regression test
added). Seven missed text-leak sites get call-site masks: delete-invoice
and credit-page invoice numbers, IB-correction voucher reference, TIC
orgnr (served unnormalized, so the separator-based scrub cannot be relied
on), articles search-term empty state, dimension segment labels, and
activate-account buttons. The attribute channel is closed with rrweb's
blockClass: inputs whose placeholder carries an effective user value
(salary overrides, correction description, danger-zone confirms, credit
confirm) get ph-no-capture, removing the element from recordings while
the prefill UX stays intact; the pivot-th title attribute is dropped.
Privacy-policy effective date bumped to 2026-08-17.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:32:45 +02:00
Mattsson 86f0b70fdd fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:45:04 +02:00
Jakob Wennberg fb3feaea12 refactor(import): de-box the migration wizard and give it a real ending (#1617)
The provider-migration wizard was a pile of cards in cards: a boxed
progress rail with a fat bar, a bordered Card per step with more bordered
boxes nested inside, amber warning panels, and a result step made of a
verdict card, orange-badged per-year cards and a green-check card grid.
All of it against the design conventions (no boxes doing hierarchy's job,
attention is one ochre sentence, status colors are data not chrome).

- Progress rail: quiet uppercase step row (done steps muted with a check,
  current in ink) over a hairline thread whose ink segment is the progress.
- Every step de-boxed onto the page: serif step headlines, hairline-divided
  provider rows and option rows with the toggle on the right, per-year
  status as quiet muted lines, Verifikationsserie as one aligned row,
  connect/preview as headline + one quiet statline.
- Amber boxes become AttnLine sentences (SIE-required gate, failed years);
  errors keep strong color as text, never tinted panels.
- Step entry animates via the standard stagger-enter grammar (slideUp,
  reduced-motion safe); row lists stagger with data-no-stagger opt-out
  from the parent cascade.
- Confirm dialog: one sentence naming what happens, compact muted
  selection list, replace-years info as muted prose; the ochre caution is
  the only colored element.
- Result step rebuilt on the reveal grammar: settled TheaterCanvas beside
  a serif verdict derived from the real results, a compact statline,
  per-year outcomes as hairline lines with warnings as one ochre sentence
  each, Övriga data as a quiet two-column line list, next steps as quiet
  numbered lines. Document follow-up restyled to the same section grammar.
- Removed dead code (MONTH_NAMES, InfoItem) and the now-unused accordion.

No logic changes: handlers, fetch flows, OAuth/document reducers and all
step gating are untouched.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:17:26 +02:00
Jakob Wennberg b556475b01 fix(import): migration preview and theater read all fiscal years, not just the newest (#1614)
The /sie-data route parsed only the newest fiscal year's SIE file for the
import preview and the returned SIEData.parsed. Mid-year provider exports
have few or zero vouchers in the newest year, so the first real Fortnox
migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer"
and drew an almost-empty migration theater while the import itself
landed all 4153 vouchers from the older files.

- New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean
  whole-dataset merge (accounts union first-wins, vouchers concatenated,
  fiscal years union oldest-first re-indexed newest=0, balances and
  issues concatenated, dimensions deduped), with unit tests.
- /sie-data parses each file exactly once, builds the preview from the
  merged parse and returns parsed: merged; response shape unchanged.
  Validation stays newest-file-only so no previously accepted dataset
  is newly rejected.
- /preview drops latestOnly and computes sieStats from the merged parse:
  the connect step's "Hittade X konton och Y verifikationer" line
  renders from THESE stats, so this is where the founder-visible count
  was lying.
- The migration theater spreads its account waves across ~10s and
  births an additional wave on each real step label during the SIE
  phase (progress <= 55), through a shared rate-limited gate, so the
  canvas keeps performing over a multi-minute run. Narration labels and
  progress remain the wizard's real values; reduced motion unchanged.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:17:11 +02:00
Mattsson ea85f4c084 feat(providers): use WINT's real logo instead of the placeholder wordmark (#1619)
public/logos/wint.svg was an explicit placeholder ("swap for WINT's official
logo asset before launch"): an Arial "WINT" text node. Replace it with the
official mark, supplied as PNG with transparency.

Downscaled from 1402x1122 / 771 KB to 256x205 / 10 KB (trimmed, 16-colour
palette) so it sits in the same weight class as the other provider logos,
which are 1.6-4 KB and render in the same 40px chip.

Both references updated: the wizard's PROVIDER_LOGOS map and the /import
LogoChip row. No wint.svg references remain.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:04:43 +02:00
Mattsson 315b94019b feat(providers): release the WINT card in the migration wizard (#1618)
WINT rendered as a disabled "Kommer snart" chip while the provider shipped
dark. The provider is wired end to end, so empty COMING_SOON_PROVIDERS and
let the card be selectable. WINT still needs WINT_MIGRATION_ENABLED=true in
the environment: that server-side /connect gate is unchanged and is what
actually authorizes the connection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:57:09 +02:00
Jakob Wennberg 9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00
Jakob Wennberg 18c20e68e6 fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals

Skatteverket connections become per (user, company): the token table
carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half
migrations), so one connection leaked "connected" onto every company the
user belongs to, sync ran the token against the wrong orgnr (behorighet
403), and reconnecting from another company silently moved the row and
went dark on the first company's crons. Token reads/writes are now scoped
by company through the whole chain (token-store, api-client refresh
coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP),
/skattekonto/saldo answers 401 NOT_CONNECTED for companies without their
own row (which is what the page's startkort keys on), and the dashboard
connect-nudge counts only the active company's row.

Bookkeeping's pristine start card now keys on all-years emptiness via a
count probe instead of "no active filters": the default fiscal-year
selection counted as a filter, which made the card unreachable on
brand-new companies (it showed "inga traffar" instead).

Two browser-global localStorage keys become company-scoped with legacy
fallbacks: the inbox onboarding dismissal (dismissing on one company hid
the card everywhere) and the periodisering auto-detect toggle.

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

* fix(scoping): dedupe cron work per (user, company) + guard the ledger probe

CodeRabbit findings on #1610: the skattekonto sync cron still deduped
token rows by user_id alone, which would drop every company but one for
multi-company operators (the exact scenario the PR fixes); and the
all-years ledger probe could leave a stale false behind on a failed
refetch, letting the pristine card render unconfirmed. The probe now
resets to unknown in flight and carries the fetch generation guard.

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-14 08:21:57 +02:00
Mattsson 4a9fa5e6c5 feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

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

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

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

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

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

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

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

* docs(decisions): log preview-tracker orphan repair after migration rename

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:57:53 +02:00
Jakob Wennberg 57d6651cfc feat(empty-states): startkort on six pages with strata imagery (#1603)
Replace the true-empty states on Kundfakturor, Transaktioner, Underlag,
Loner, Bokforing and Skattekonto with StartCard: a self-contained dark
hero (image-derived ground baked into the strata render, white primary
CTA) that says what the page can do instead of what is missing. Primary
CTAs lead with the connect/setup action per page (bank via PSD2 deep
link, mailboxes, Skatteverket, migration import); filtered/search empty
states and viewer fallbacks keep the old compact states. Design signed
off in the Startkort prototype iterations 2026-08-13.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:27:24 +02:00
Jakob Wennberg 6c64dd2312 fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563)
* fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552)

Silence was a legitimate outcome in seven places and none left a trace a
support question could be answered from. Now:

- Unknown-sender declines (over quota, quota RPC failure, greeting
  throttle) persist content-free trace rows: wamid, phone hash, type,
  disposition. No body, media, raw payload, or profile name; capped at
  20 rows per hash and day; deleted by the existing 30-day retention.
  The wamid dedupe also stops redelivered bad-code/greeting messages
  from earning a second reply.
- Linked-sender deliberate silences (muted, stale tap, ignorable type)
  record their reason on the skipped row.
- Non-policy silences reply: a row missing its media reference sends
  M18 through the link's reply address, a link revoked between arrival
  and processing sends the M1 unlinked copy (greeting-throttled).
- Outbound rows keep WHY a send failed (Graph error detail), and Meta
  'failed' delivery statuses store their error code and title.
- The WhatsApp settings panel shows the last inbound event (closed
  enum, server-derived) and warns when the latest reply never left.

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

* test(whatsapp): include errorDetail in typed sendText mock results

SendTextResult gained errorDetail; vi.mocked call sites must match the
widened type or they raise fresh tsc errors over the repo baseline.

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

* fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces

From CodeRabbit's pass on #1563:
- greetingThrottled fails closed when the throttle window cannot be
  read, matching the unknown-sender quota's stance.
- The decline-trace day cap applies only to 'skipped' rows (the one
  unbounded path); 'done' traces always insert so the wamid dedupe
  keeps preventing duplicate M1/M2 replies even past the cap. Their
  volume is already bounded upstream by the greeting throttle and the
  pre-binding quota.
- company-question test mocks match the widened SendTextResult.

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:02:16 +02:00
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 38a890c8d1 fix(underlag): carry the phone photo that is too big to send, and say why when we cannot (#1550)
* fix(whatsapp-inbox): register the channel question event types

Every follow-up question the WhatsApp intake asks has been failing its
processing_history append in production: ChannelQuestionAsked,
ChannelQuestionAnswered and ChannelQuestionExpired were never added to
the processing_event_types catalog the event_type FK points at.

appendQuestionHistory() catches and logs that failure by design, so the
reply to the sender still goes out and nothing looked broken from the
outside. What was lost is the durable record of the exchange, which is
part of how the underlag was obtained (BFNAR 2013:2 kap 8).

Catalog rows only: aggregate_type 'System' already passes the CHECK.

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

* fix(underlag): say why an upload failed, and get out of an expired session

A user reported that none of the three ways to add a receipt from a
phone worked, all of them answering "Uppladdning misslyckades. Nagot
gick fel, forsok igen" immediately. Production told us nothing: every
upload request that reached the route in the same 24 hours returned 200.

Both halves of that are the same bug. The workspace read failures as
`throw new Error(json.error)`, which loses a body that is not JSON (the
res.json() call throws first) and stringifies the structured envelope to
"[object Object]", so anything the route did not answer with a plain
string arrived as the generic fallback. The middleware 401 for an
expired cookie session is exactly that envelope shape, and a phone tab
left open is exactly where the session expires unnoticed: the
controller's timers are throttled in the background, so the request the
user just made is what finds out.

Now the response is resolved where it fails, through the house helper
that already knows the status map, and an expired session is announced
on the session-timeout BroadcastChannel so the controller signs out and
routes to /login the same way it does for an expired heartbeat. Failed
uploads also post metadata (status, size, mime type, resolved reason) to
/api/log, the one API path exempt from the timeout gate, so a request
answered before the route runs stops being invisible.

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

* fix(underlag): carry the phone photo that is too big to send

The reported failure was not the account and not the session: hosted
rejects any request body over 4.5 MB itself, before the function runs.
Measured against production, 4.4 MB reaches the route and 4.6 MB comes
back as a plain-text FUNCTION_PAYLOAD_TOO_LARGE. Nothing invokes the
function, so nothing lands in the logs, which is why one user's failing
uploads were invisible while every upload that arrived returned 200. An
iPhone photo in "Most Compatible" mode is 4-12 MB, so whether it worked
depended on whose phone took the picture. Meanwhile the route advertises
a 10 MB limit it can never be handed.

Photos are now re-encoded in the browser when they exceed what the
platform will carry: 2400px on the long edge at JPEG q0.85, stepping the
quality down only if that is not enough. That keeps the small print on a
receipt legible, which is what BFL 7 kap asks of an archived underlag
("varaktigt läsbart skick", a faithful reproduction), and a refusal is
not. What cannot be shrunk (a PDF, or HEIC where the browser will not
decode it) is refused before the upload starts, naming its actual size
and the limit rather than failing in transit.

413 joins the HTTP status map so a rejection we cannot pre-empt still
says what happened: the platform's body is plain text, so the status is
the only thing there is to translate.

Self-hosted Docker has no proxy in front of the app, so none of this
applies there and the route's own MAX_FILE_SIZE keeps governing.

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-13 09:39:23 +02:00
Mattsson a07dcf4a55 feat(inbox): visible differens + double-click balance fill in Bokför direkt (#1544)
* feat(inbox): show differens in Bokfor direkt sum row + double-click balance fill

User feedback: when hand-computing VAT the unbalanced amount had to be
worked out manually; the existing diff indicator was tiny, muted and below
the fold. Now the remaining debit/credit gap renders red in the Summa row
(and the sums turn red) while unbalanced, matching JournalEntryForm.

Also ports JournalEntryForm's opt-in balancing: double-clicking a debit or
credit field fills the amount that balances the entry (no-op when balanced
or when the fill belongs on the other side), with a hint line replacing the
old indicator. Display-only + client-side prefill; the engine still rejects
unbalanced entries.

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

* refactor(inbox): use roundOre for balancing diff per antipattern guard

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 01:04:30 +02:00
Mattsson 27383fd02b fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker (#1543)
* fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker

The account combobox in the inbox book-directly flow was fed only the
company's active chart, so typing a prefix like 65 showed just the two
activated 65xx accounts and a search for 6540 found nothing, which reads
as the account not existing. Pass the cached BAS catalogue (same pattern
as JournalEntryForm) so every standard account is searchable; picking a
not-yet-activated account flows through the existing
ActivateAccountsDialog rail at booking.

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

* fix(bookkeeping): log BAS catalog fetch failures

Compliance swarm finding (SOC 2 CC7.2): loadBasCatalog swallowed fetch
errors silently, leaving catalog-load failures unobservable. Log inside
the client's catch, which is the only place the error actually surfaces:
callers' own .catch handlers are unreachable since the shared promise
already resolves to an empty list on failure.

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

* test(bookkeeping): unit-cover loadBasCatalog fetch, fallback, and retry

CodeRabbit finding on PR #1543: the catalog client had no focused
coverage. Tests assert the success path with promise caching, empty-list
fallback with logging on non-OK responses, missing data field handling,
and cache clearing after a failure so the next call refetches.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:55:33 +02:00
Mattsson 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
Mattsson 402bf02b1b fix(inbox): carry the matched transaction amount into manual booking (#1546)
PR #1524 swapped the matched-state "Bokfor manuellt" path from
BookDirectlyDialog to EditKonteringDialog, which is seeded only from the
booking proposal. An unknown supplier has no proposal, so the dialog
opened with two blank rows and no amount at all: on a foreign-currency
invoice the SEK figure then existed nowhere on screen (user-reported
regression, 2026-08-12).

suggest-booking now returns, on every empty-proposal branch (no_mapping,
currency_unsupported, engine failure), the matched bank row's SEK amount
and date plus a balanced two-row skeleton: the settlement account on one
side, a blank cost row on the other, mirroring what buildPrefillLines
seeded before the swap. The SEK amount goes through
resolveSekAmountOrNull, so a foreign row with no honest kronor figure
still opens blank rather than relabeling EUR as SEK. The dialog also
shows the matched transaction's amount and date beside the title, and
empty proposals now carry the bank date so the entry no longer falls
back to the document date.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:45:46 +02:00
Mattsson a97b0023d4 feat: import Fortnox voucher attachments (#1541)
* feat: import Fortnox voucher attachments

* fix: show Fortnox document import follow-up

* fix: harden optional Fortnox document import

* test: pin optional Fortnox import flow

* fix: use browser timer handle type

* fix: avoid serializing OAuth resume state
2026-08-13 00:33:10 +02:00
Mattsson aff29b2b05 feat(inbox): show extracted fakturadatum on inbox cards (#1542)
* feat(inbox): show extracted fakturadatum on inbox cards

Users with many invoices from the same supplier could not tell the
cards apart: the list only showed supplier name, arrival time, and
amount. Append the extracted invoice date (formatDate, tabular-nums)
after the arrival time on each card when extraction found one.

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

* refactor(inbox): deduplicate the card timestamp span

The timeAgo + fakturadatum span was rendered verbatim in both branches
of the second-line conditional. Compute it once per row instead
(PR Agent review note on #1542).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 23:48:52 +02:00
Jakob Wennberg 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
Jakob Wennberg 05b0c57ecd docs(inbox): the onboarding card described the page as it used to be (#1527)
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: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:12:24 +02:00
Jakob Wennberg 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
Jakob Wennberg 555a2a20ae feat(inbox): Underlag rebuilt to answer what is missing, where to get it, and how it would be booked (#1524)
* fix(mail): stop Gmail refusing the search, and stop calling that "hittade inget"

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: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(inbox): segment filter as a dropdown, not three rows of pills

Five filters wrapped to three lines in a 280px column. The counts are what
people actually read, so they stay on the trigger and inside the menu
rather than being traded away for the space.

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

* feat(inbox): one chip for where underlag come from

Three routes in, and the page never said so: the forwarding address sat
inline in the header, the mailboxes lived only in Instaellningar, and
WhatsApp was invisible here entirely.

They are behind one chip now. Which mailbox and when it was last read is
what people look up when something seems wrong, not what they read every
visit, so it opens rather than occupying the header.

A mailbox that has stopped working is the exception, so it surfaces on the
chip itself rather than waiting to be found one click in. That silence is
the failure this feature exists to catch.

Configuration stays in Instaellningar; this only reports.

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

* feat(inbox): the kontering first, the evidence folded

Reading order was backwards. Nine extracted values came first and the one
thing to approve came last, so every matched item meant scrolling past the
evidence to reach the decision.

The proposed kontering is now the first thing in the rail. The fields fold
behind a summary that carries how many of the twelve the extraction
actually filled, so a thin extraction is visible without opening it.

They stay open when nothing is matched: with no proposal above them the
fields are all there is, and folding the only content on the pane would be
a hiding place rather than a hierarchy.

The counted list is the same one hasAnyExtractedField checks, so the
summary cannot claim a field the 'is anything here' test does not count.

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

* feat(inbox): one dialog that changes the whole verifikat

The rail offered three overlapping ways to alter a booking and none said
what it covered: an Aendra beside the date, an Aendra kontering at the
bottom, and a menu entry that did what the primary button already did.

This is the one control, and its scope is the whole verifikat: date,
series, description, every line. It opens pre-filled with the proposal
when there is one and empty when there is not, so there is no separate
book-manually path to pick between.

A dialog rather than an inline editor: a 340px rail cannot hold an account
picker, two money columns and a delete control per row without clipping
something, and the document has to stay readable while the numbers change.
Checking a momssats against the paper is the reason to open it at all.
TransactionBookingDialog already has this shape for the same reason.

The form is JournalEntryForm unchanged. It carries the series picker, per
line descriptions, dimensions, currency, the balance check and the confirm
step, and it posts through the sanctioned route. Extending
BookDirectlyDialog was the alternative and is not viable: three effects
seed its lines and fight anything injected, and its FormLine has no room
for line text, dimensions or tax codes.

Nothing posts without the form's own review step, so a proposal stays a
draft the user commits.

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

* fix(inbox): show every unreceipted purchase, and fold the mailboxes

Three things.

The 100 kr floor was hiding 52 of one real company's 119 unreceipted
purchases: the page reported 67 and looked tidier for it. The floor was
copied from the receipt hunt, where it earns its place because every
candidate costs a mail search and a model read. This list costs a query,
and bokforingslagen wants an underlag for the 45 kr purchase exactly as
much as for the 4 500 kr one. The hunt keeps its floor; the page has none.

Mailboxes fold. When it was last searched is what you look up when a
mailbox seems to have gone quiet, not what you read on the way past. The
address stays on the row, and a connection that needs reconnecting still
says so without opening.

Dropped the line telling people to go to Instaellningar. The panel reports
where underlag come from; sending them elsewhere was the seam this work
set out to close.

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

* feat(inbox): split the portal purchases out, and say what a run found

Four things from looking at the real page beside the artifact.

Hamta fran portal is its own list again. Twelve of one company's 119
unreceipted purchases have a supplier whose invoices sit behind a login,
and that is a different job from the other 107: go there and fetch it,
versus ask somebody. Collapsing them into one list with a badge buried the
twelve you can settle now among the hundred you cannot.

A run now says what it did. Pressing Leta and being told nothing is why
the feature read as broken even on the runs where it worked: three
underlag landed and the page looked identical afterwards.

WhatsApp folds like the mailboxes and shows its number, which is the fact
worth having. Describing the channel to someone who already connected it
was not.

The forwarding address lost its subtitle, and WhatsApp rows carry the
brand mark. Emailed documents keep the generic one: nothing records which
mailbox fetched them, so claiming a provider would be a guess.

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

* fix(inbox): the WhatsApp number, three wrong portals, and somewhere to drop the file

The WhatsApp row read the response in snake_case while the route answers
camelCase, so a linked number rendered as a dash and a verified link read
as unverified. Reading phoneMasked and verifiedAt fixes both.

Anthropic, Vercel and Supabase are out of the portal directory. All three
email their invoices to European customers, so listing them told somebody
to go and log in for a document already sitting in their inbox: worse than
saying nothing, because it sends them away from the answer. The directory's
bar is 'does not send the invoice', not 'also has a portal'. The poll it
was seeded from asked which portals people log into, and people answered
with where an invoice can also be found. The same objection may reach
further down the list.

A purchase with no underlag now offers somewhere to put one. Telling
somebody a document is missing without a place to drop it is half an
answer, and the drop zone carries the amount and the date so the right
file goes to the right purchase.

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

* fix(portal): the links were never opened, and two of them were wrong

The directory shipped with eighteen hand-written paths and none had been
clicked. The file said so in its own header and shipped regardless, which
is how a founder came to land on a 404 opening Google Workspace.

A sweep of every URL found GitHub broken as well. Google Workspace now
points at the console root rather than a deep billing path: admin.google.com
refuses automated requests, so no deeper path can be verified from here,
and a link that lands one click short beats one that lands on an error
page. GitHub points at the path that actually answers. Trygg Hansa is
removed because neither candidate URL could be reached at all, and an
unverifiable link is exactly the promise this file kept warning about.

scripts/check-portal-urls.mts sweeps them, so the next wrong URL is found
by a script rather than by somebody who trusted the link. A 404 fails it;
a host that refuses automation reports as unreachable and does not, because
failing on those would train people to ignore the output.

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

* fix(inbox): the drop zone now actually attaches the file to the purchase

It did not. The generic upload sends only the file, so a document dropped
while a purchase was selected landed in the inbox unmatched, while the
pane showed that purchase's amount and date directly under the drop zone.
The copy promised a link the code never made, and the user was left to
match by hand what they had already told us.

Uploading from a selected purchase now matches the new item to that
transaction through the endpoint that already exists, and a file dropped
anywhere on the page while a purchase is selected counts as that
purchase's receipt rather than a loose upload.

When the match fails the document is still safely filed, so it says so
plainly instead of claiming a link that is not there.

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

* fix(inbox): book the underlag against its transaction, and stop claiming links

Two blockers found by review, both on the path that writes to the ledger.

"Granska och bokför" never sent transaction_id. JournalEntryForm
serialises a fixed set of keys and that is not one of them, and
BookInboxItemDirectlySchema is a non-strict z.object, so the source_id
carrying it was silently stripped. The verifikat posted standalone, the
bank transaction stayed unbooked, and matched_transaction_id was
overwritten with null: the match somebody had already made, undone, while
the rail said Bokförd over all of it.

Fixed in three places because one was not enough. JournalEntryForm takes
an extraBody passthrough, the dialog sends transaction_id through it, and
the route now falls back to the item's existing match rather than null, so
a caller that merely forgets the field cannot undo work. Removing that
fallback fails the new test.

The hunt banner said "kopplades till ett köp" about pending_operations
rows. The hunt stages proposals for approval and books nothing, so the
number was real and the word was wrong: a user would read it, believe
three purchases were done, and leave. It now says how many förslag await
granskning, and links there.

Booking also left the rail in its pre-booking state, still offering to
post, so the same underlag could be submitted twice.

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

* fix(inbox): no marker on a healthy state, no false empty state, no dropped files

Three from review.

The sources chip painted a sage dot whenever every mailbox was fine.
Convention 12 rules semantic colour out of chrome, and convention 5 rules
out a marker on a normal state: a chip every company sees always is a chip
that says nothing. What is left is the exception, which is worth an ochre
word and an icon. The pre-existing sage on matched rows is untouched; it
is not this branch's to change.

The empty state asserted "Varje köp har sitt underlag" while the trigger
directly above it still showed the unsearched count. Type a term under Att
göra, switch to Saknar underlag, and the page told you every purchase was
covered while the button beside it read 50. It now says what is true: no
matches for that term.

A drop of several files onto a selected purchase kept the first and
discarded the rest in silence, so a receipt scanned as two images left the
purchase looking resolved with half its paperwork gone. They cannot all be
one purchase's underlag, so the extras are filed in the inbox and the
toast says how many.

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

* fix(inbox): the hunt banner now says a press is not the last word

A press fetches a bounded number of receipts, so an empty result usually
means not yet rather than nothing there. The banner said 'Inget matchade
något köp' and stopped, which reads as final and sends people away from a
mailbox that still holds their receipts. It now says how many purchases
are left to search for, and to press again.

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

* fix(inbox): a count not a score, an honest failure, full-opacity borders

'5 av 12' read as a bad extraction even when a kvitto had given up
everything a kvitto has: half those twelve fields only exist on an
invoice, so the denominator was measuring the document kind rather than
the reading of it. It now says how many fields are filled, and says
nothing when none are.

The failure banner told people their mailbox had not answered even when
the failure was ours, sending them to check a healthy Gmail. It now reads
searchFailures and only blames the mailbox when a mailbox actually refused.

Opacity-suffixed borders on the sources panel, which design.md forbids on
surfaces: the border token is calibrated for full opacity.

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

* feat(inbox): translate the new strings, and name the mailbox that fetched a receipt

Both of these were deferred with reasons, and one of the reasons was wrong.

57 keys in inbox_workspace, in both locales, covering every string this
branch added. The component already had 27 t() calls, so hardcoding beside
them was an inconsistency rather than a convention. The message-keys guard
caught an invented journal_form.no_document on the way, which is what it
is for.

The provider mark claimed nothing recorded which mailbox fetched a
document. It does: lib/receipt-hunt/ingest.ts writes mail_provider and
mail_mailbox into channel_context on every ingest, and GET /items already
selects that column. A hunted receipt now carries the mark of the mailbox
it came from; forwarded mail has no connection behind it and keeps the
envelope, which is the honest distinction rather than a guess.

InboxChannelContext was WhatsApp-shaped and is now a union over the two
intakes that write it.

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

* fix(agent-context): keep the clarification channel narrow

Widening InboxChannelContext.channel to cover the mail hunt broke this:
only WhatsApp asks a human anything, so only WhatsApp produces
clarifications. The mail hunt writes the same column with its own shape and
never carries answers, so the provenance field stays 'whatsapp' rather than
following the union.

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

* fix(inbox): book the transaction we preserved, and date the verifikat by the event

Three from PR review, two of them real.

Preserving matched_transaction_id without booking it was the worse half of
the bug it fixed. The transaction update was still guarded on the caller
having sent transaction_id, so an omitted field left the item looking
resolved while its bank line stayed open forever. Both the update and the
item now use the same resolved id: the one the caller named, or the one
the item was already matched to. Reverting the guard fails a test.

The verifikat date fell back to today when there was no proposal, which is
exactly the unknown-supplier case the dialog exists for. BFL 5 kap 6-7 §
asks for datum för affärshändelsen; the day somebody opened a dialog is
nobody's business event. It now falls back to the document's own date
first, and only then to today.

An en dash had crept in as a placeholder glyph, which the repo bans.

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 14:36:08 +02:00
Mattsson 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
Jakob Wennberg 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
Jakob Wennberg bf220eac3c feat(inbox): run the receipt hunt from the page it fills (#1519)
* feat(inbox): run the receipt hunt from the page it fills

The hunt could only be started from Settings. The page where a person
notices that receipts are missing had no way to go and look for them, and
the button that fixes it sat behind a different navigation item. That seam
is the kind that makes a working feature look broken.

"Leta i mejlen" now sits in the Underlag header, next to Ladda upp, and
only when a mailbox is actually connected: offering it otherwise promises
something it cannot do.

The loop moves into a shared hook rather than being copied. It belongs to
neither surface, and two implementations of "when does a run stop" would
eventually disagree about the one thing that matters, which is that a pass
finding nothing new means the mailboxes hold nothing more for the
purchases still open.

Each pass refreshes both lists, so a run fills the page as it goes instead
of all at once at the end. That matters more here than in Settings: a pass
can attach a document to a purchase, which moves a row out of "saknar
underlag" and into the inbox, and watching that happen is the feedback
that the button did something.

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

* fix(inbox): only offer the hunt when a mailbox can actually be searched

Counting connection rows does not answer whether anything is searchable. A
revoked or expired connection is still a row, and the hunt skips it, so the
button promised a search that would return nothing on every pass.

A dead mailbox that still looks healthy is the exact failure this feature
exists to surface. Starting by doing it in its own header would be a poor
joke.

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-11 12:53:45 +02:00
Jakob Wennberg 7a49aec0c3 feat(inbox): show the purchases that have no underlag (#1518)
The page has always listed documents, so a purchase with no document at
all could not appear on it. The one thing a user most wants from a
document inbox, "what am I still missing", was the one thing it could not
answer.

A "Saknar underlag" filter now lists them, from the endpoint added
alongside. Selecting one shows the purchase and, when the directory knows
the supplier, a link to where the invoice lives. We never log in for
anyone: knowing where it is costs no password and is most of the value.

The pill appears only when there is something behind it. A company that
keeps every receipt should not be shown a permanent empty accusation.

Purchases live beside `items` rather than inside it. Widening InboxItem to
cover a bank row would push a null document, a null extraction and a null
status through every consumer of that type, to describe a row that never
becomes an inbox item.

The middle pane says why there is no file instead of rendering an empty
frame, and the two selections clear each other: the panes show one kind of
row at a time, so a stale selection from the other kind would outlive its
list.

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 12:33:58 +02:00
Jakob Wennberg cd9b540401 feat(inbox): show what a matched underlag would be booked as (#1515)
* feat(inbox): show what a matched underlag would be booked as

The rail told you what was extracted and never what would happen. A
matched item offered "Bokför manuellt" and a dialog, so the only way to
learn the answer was to open the dialog and read it there.

The proposal now sits in the rail: the accounts, the amounts, the day it
would be booked, and a folded reason. Read-only, because convention 14
says nothing AI-suggested posts without review; the dialog below is still
the only way to commit it.

When there is no proposal it says why in a sentence instead of showing an
empty table. An unknown supplier gets "bokför manuellt en gång, så känns
den igen nästa gång", which is true and actionable. A foreign-currency row
matched by a konteringsregel says the moms would be wrong, because it
would.

Balance is stated only when it fails. The lines come from the same builder
the commit path uses and balance by construction, so a mismatch means
something upstream is wrong and the user should see it before booking
rather than after.

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

* fix(inbox): name the money columns and abort superseded suggestions

Two from review. Debet and Kredit were distinguishable only by column
position and a muted tint, which the design rules rule out: colour is
never the sole indicator. They have headers now.

The suggestion fetch cancelled its setState but not the request. Arrowing
down a list fires one per row, so the superseded ones still ran to
completion server-side and a slow one could resolve after a faster later
one. AbortController now cancels them.

Left alone: lifting the response type into @/types. It is the contract
between one route and one component, and a second declaration is a second
thing to keep in step.

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-11 12:14:49 +02:00
Jakob Wennberg 1e2b952f9c fix(inbox): one frame around Underlag, not two (#1513)
* fix(inbox): stop an inline field edit from wiping the AI classification

PATCH /items/:id/fields rebuilt extracted_data from a hand-written list of
six keys. Everything outside that list was destroyed the first time somebody
corrected a single field by hand: documentKind, merchantCategory, legibility,
purchaseTime, payment and suggestedTemplateId.

Nothing surfaced the loss. The row kept working, the edit landed, and the
classification simply stopped being there. It is not recoverable afterwards
without re-running extraction, so rows edited before this fix have already
lost it.

The comment above the merge names the three fields it does preserve, which
reads as though the list were exhaustive. It never was: those six arrived on
InvoiceExtractionResult later and nobody came back here. Spreading `current`
first fixes the six and, more usefully, means the next field added survives
by default rather than waiting to be noticed missing.

The tests pin the merge rather than the six names. One walks every key that
was on the row and asserts it is still there, so a field added tomorrow is
covered without anyone editing the test. Removing the spread fails two of
them with "`documentKind` was dropped by the merge".

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

* fix(inbox): one frame around Underlag, not two

The workspace wrapped itself in `p-4 md:p-6` and then a second
`rounded-lg border bg-card`, inside the dashboard panel that already
supplies a border, a 12px radius and the background. The result was two
rounded frames 24px apart with mismatched radii (12px outer, 8px inner)
and a dead inset all the way round.

`/e/` routes render full-bleed on purpose: MainContainer sets isFullBleed
for them and the comment there names multi-pane layouts as the reason. The
workspace should fill that panel, not draw another one inside it.

The DOM nesting is unchanged so the absolute drag overlay keeps its
positioning context; only the padding and the card chrome come off.
`bg-card` is safe to drop because card background matches page background
by design, the border is what carries hierarchy.

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-11 11:05:26 +02:00
Jakob Wennberg f0ce322a7c feat(receipt-hunt): keep looking until the mailboxes stop yielding (#1499)
A press fetched at most three receipts, because fetching one means
downloading it and having a model read the PDF, and a backlog of forty
cannot finish inside one serverless invocation. Working through a real
backlog meant pressing the button a dozen times, which is the shape of a
stopgap rather than a feature.

The loop now lives in the page: it keeps asking for another bounded pass
until one comes back with nothing new, which is the honest signal that the
mailboxes hold nothing more for the purchases still open. Progress shows
as it goes and there is a Stop, read inside the loop so it takes effect on
the current pass rather than after every remaining one.

Deliberately not a queue drained by cron. The finest schedule this app
runs is hourly, so a queue would mean pressing a button and waiting an
hour for anything to happen: worse than what it replaces, and a new table
and worker to maintain. The cost of putting the loop in the browser is
that it runs while the tab is open, which is acceptable for work somebody
just asked for.

A cap of 25 passes is a backstop, not a budget: it exists so a pass that
keeps reporting work it never completes cannot run forever.

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 19:15:40 +02:00
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 51bf1b77bf feat(import): mount the knowledge-graph theater on the Arcim migrating step (#1486)
* feat(import): stream real Arcim migration progress as NDJSON (#1485)

* feat(import): stream real Arcim migration progress as NDJSON

The /migrate route ran the orchestrator to completion and answered with one
JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and
a static step label for a phase that can take minutes. The orchestrator has
had a real onProgress channel (eight emit points with Swedish step labels
and anchors) since it was written; the route just never passed it.

Now a request with Accept: application/x-ndjson gets a streamed response:
one line per orchestrator progress event, then a terminal done line with
the results or an error line carrying the same structured envelope the
JSON path returns (the 200 status is already committed once the stream
opens). Callers without the header keep the original single-JSON contract,
so pre-deploy tabs and the existing error-mapping tests are untouched.

The wizard opts in, drives MigratingStep from the real labels and anchors
(mapped onto the 55-100 slice of the wizard bar), and treats a dropped
connection as unconfirmed rather than failed, since the migration keeps
running server-side and a blind retry could double-import.

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

* docs: record the opt-in NDJSON streaming decision for /migrate

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>

* feat(import): mount the knowledge-graph theater on the Arcim migrating step

The migration wizard already holds the fully parsed SIE client-side
(SIEData.parsed from /sie-data), so the same TheaterCanvas that carries
the /import flow can build the company's knowledge graph while the
migration runs: no server change, and the plain progress card stays as
the fallback whenever no parsed SIE exists (e.g. providers without SIE).

Unlike /import's fixed narration script, the wizard knows exactly what
the server is doing: phase 1 posts one SIE file at a time and phase 2
streams the orchestrator's real progress events. ArcimMigrationTheater
therefore narrates by printing those real step labels once each as they
arrive, and keys the canvas to the same milestones: the GL skeleton
(rings, buckets, accounts) builds during the journal writes, counterparty
waves attach while customers and suppliers import, and reconciliation
pulses. Real progress bar and elapsed counter stay visible throughout.

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

* fix(import): review fixes on the migration theater and stream close

CodeRabbit on #1486: (1) the aria-live region wrapped the per-second
elapsed counter, so a screen reader re-announced the timer every second
and drowned out the real step labels; the live region now covers only the
narration list and the timer row is aria-hidden (the Progress bar exposes
its own ARIA value). (2) controller.close() in the stream's finally block
throws if the reader already cancelled, escaping start() as an unhandled
rejection; now guarded like send().

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 09:43:32 +02:00
Jakob Wennberg 1d01010928 feat(import): stream real Arcim migration progress as NDJSON (#1485)
* feat(import): stream real Arcim migration progress as NDJSON

The /migrate route ran the orchestrator to completion and answered with one
JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and
a static step label for a phase that can take minutes. The orchestrator has
had a real onProgress channel (eight emit points with Swedish step labels
and anchors) since it was written; the route just never passed it.

Now a request with Accept: application/x-ndjson gets a streamed response:
one line per orchestrator progress event, then a terminal done line with
the results or an error line carrying the same structured envelope the
JSON path returns (the 200 status is already committed once the stream
opens). Callers without the header keep the original single-JSON contract,
so pre-deploy tabs and the existing error-mapping tests are untouched.

The wizard opts in, drives MigratingStep from the real labels and anchors
(mapped onto the 55-100 slice of the wizard bar), and treats a dropped
connection as unconfirmed rather than failed, since the migration keeps
running server-side and a blind retry could double-import.

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

* docs: record the opt-in NDJSON streaming decision for /migrate

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 09:26:27 +02:00
Jakob Wennberg 02a5d10538 refactor(ui): migrate remaining inline pages to the concept design language (#1470)
* refactor(ui): migrate remaining inline pages to the concept design language

Catch-up pass for surfaces the 2026-07 UI migration missed:

- Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade
  verifikationer, ignorerade, matchade), instructional copy moved behind the
  page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty-
  dates hint, EmptyState for the blank page, space-y-8 rhythm.
- Report detail views (trial balance, income statement, balance sheet,
  resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L):
  shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened
  to hairline cards with font-display tabular-nums headline numbers,
  ReportSectionTable rebuilt on the group-band idiom, font-mono money ->
  tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed
  onto Input primitives, verdicts follow chips-mark-exceptions.
- Extensions browse: PageHeader, locked section headers, rounded-lg
  secondary icon tiles, flat hover shift on cards, p-6 content.
- Återkommande fakturor: page-level list moved off ui/table onto dry-table
  with hover-revealed quiet row actions; Skeleton loading.
- Help: EmptyState for no search hits, flat hover shift on resource links.
- Chart of accounts: spinner loading blocks -> Skeleton rows.
- Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea
  primitives instead of hand-rolled shadow-sm controls.

No logic, endpoint, or data changes. Verified via sandbox screenshots;
lint 0 errors, 13214 tests green.

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

* fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit

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-08 16:40:19 +02:00
Mattsson 39f4ecdad4 fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

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

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

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

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:04:58 +02:00
Jakob Wennberg c7dd78b0a3 feat(onboarding): branch question on the journey done screen (#1468)
* feat(onboarding): branch question on the journey done screen

Second slice of the approved activation concept: the moment the company
exists, the done screen asks "Var fanns bokföringen innan?" with
provider chips (real logos), SIE file, and new-business options, plus a
quiet look-around escape. Choices persist initial_setup_path
(fire-and-forget) and deep-link into the existing flows: providers jump
straight to the migration wizard's connect step (sieViaApi providers
only; Visma/Bokio land on the provider list where the SIE-first gate
lives), the SIE chip opens the upload step, new business lands on Hem
with step one checked off.

mode='add' keeps the plain "Öppna Accounted" button: the concept's own
guard, and it avoids writing the path onto the previous company if
setActiveCompany silently failed. Routing lives in a pure helper with
tests; anonymous onboarding_branch_chosen funnel event follows the
guarded capture pattern.

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

* fix(onboarding): review triage: single-choice latch, preselect reset

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

* fix: restore package-lock.json to main (worktree npm install mutated 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-08 15:27:17 +02:00
Jakob Wennberg a49d75db77 fix(migration): Visma pagination + chunk-insert resilience (the '300 misslyckades' case) (#1455)
* fix(providers): paginate Visma eAccounting with $page/$pagesize

eAccounting silently ignores OData $top/$skip, so every request returned
page 1 and getPaginated appended the first page TotalNumberOfPages times:
customers were imported in triplicate and invoice chunks hit unique
violations. Also stop on an empty page so a stale Meta can never loop or
duplicate.

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

* fix(migration): survive bad rows in entity imports instead of failing whole chunks

One PostgREST insert per 500-row chunk is all-or-nothing, so a single
duplicate reported every row as failed ('300 misslyckades') with no cause
shown. Now: dedupe repeats within the fetched data (paging faults, source
duplicates), fall back to per-row inserts when a chunk is rejected, store
empty invoice numbers as NULL instead of colliding '', surface the first
DB error in the result UI, and mark all-failed steps with an error icon.
Sales invoices also carry remaining_amount so open invoices no longer
land as settled.

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

* fix(migration): never per-row retry after a successful bulk insert with short read-back

A succeeded statement whose .select() returns fewer rows than sent means
the rows ARE in the table; retrying them one by one would duplicate every
unreturned row. Pair what came back and report the tail instead.

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

* fix(migration): count stub-insert casualties as failed and sample enrichment errors

Review follow-ups: invoices dropped because their customer/supplier stub
insert errored are DB failures, not matching misses; classifying them as
noMatch rendered a green result row with the database error hidden.
Enrichment failures now also feed errorSample.

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-08 10:45:02 +02:00
Mattsson 93f81f03e8 feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED

Adds WINT (wint.se) as a sixth migration provider, built against the
OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the
partner-facing v1 endpoints are used; the general ledger is fetched as
vouchers/accounts and rendered as SIE 4E by our own sie-builder, with
opening balances for earlier years derived backward from the current-year
Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair;
the password is never stored.

Ships dark: the wizard shows a disabled "Kommer snart" card, and the
server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true.
Live verification against a real WINT account is still outstanding.

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

* fix(providers): harden WINT provider per PR #1446 review findings

Addresses CodeRabbit and Swedish accounting review feedback in one pass:

- Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an
  active year outside the allowed import window can never silently anchor
  the wrong year; the voucher chain is extended through the anchor and a
  per-year fetch failure fails that year loudly instead of sinking the
  whole migration.
- Auth token exchange is strict: only LoginState Success with a complete
  access+refresh pair mints a consent (a pair without a refresh token is
  unrefreshable and would break days later).
- WintApiError no longer retains full response bodies (bounded 300-char
  diagnostic; bodies can carry customer data and errors get logged).
- sie-builder refuses to render structurally invalid vouchers (missing
  account number or booking date) and documents deleted-voucher gaps in a
  #PROSA record per BFL 5 kap 6-7 §.
- Account classification: 20xx is equity, 83xx is financial income.
- SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it
  previously produced a false non-BAS warning on every WINT/Bollbok file).
- New tests: resolveConsent WINT refresh flow, credential upsert payload
  (no mail/password persisted), WINT fetch failure path, EUBAS97 warning
  regression, builder invalid-data rejection, vi.clearAllMocks hygiene.

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

* fix(import): pin EUBAS97 acceptance to the exact SIE spec value

Review follow-up on PR #1446: match EUBAS97 exactly instead of any
EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four
kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97,
NE2007) rather than silently accepting unknown future variants.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:07:14 +02:00
Jakob Wennberg c0a106e591 feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date

BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the
initial VAT treatment, overriding the server's per-category derivation and
claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit
'Enligt kategori' option that omits vat_treatment so the server derives it
(exempt bank/card fees, 12% representation). Reverse charge is never derived.

The embedded JournalEntryForm period Select is replaced by the same derived
read-only text the standalone variant already uses: the period is a total
function of the entry date, and the Select allowed picking a period that
disagreed with it.

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

* feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt

BookDirectlyDialog and the supplier-invoice form left the cost account
deliberately blank even when the company's own confirmed history for the
counterparty (categorization_templates) or supplier.default_expense_account
knew the answer. Both now prefill from a counterparty-template hit (new
?counterparty= single-match mode on the settings route, same tiered matcher
as the booking flows), only into still-empty fields, only from expense-shaped
templates, with a provenance line. No generic fallback: a miss leaves the
field blank exactly as before.

Bokfor direkt's period Select is replaced by text derived from the entry
date; the silent periods[0] fallback becomes a blocking explanation, since
borrowing an arbitrary period could book into the wrong one.

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

* feat(ux): single-company login skips the picker; filing surfaces default to filable periods

/select-company auto-forwards when the user is a member of exactly one
company with nothing else to decide (no new TIC engagements, no pending
invite, enrichment fresh); the in-app 'Lagg till foretag' links pass
?choose=1 to keep the picker deliberately reachable. Byra/multi-company
users are untouched.

The VAT declaration now opens on the most recently ENDED month/quarter
(lib/vat/period-defaults, tested) instead of the current one, which can
never be filed and forced a step-back click on every filing visit; the
periodicity switch resets the same way. Helarsmoms FyPicker gains
preferLatestEnded and opens on the latest ended rakenskapsar instead of
the newest started one.

The 'momsperiod saknas' dead end now collects the answer inline through
the same PUT /api/settings validation instead of bouncing to settings:
until the period exists the deadline engine generates zero VAT deadlines,
silently, so every extra hop kept a compliance hole open.

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

* feat(granskning): approve pill commits directly for low and medium risk

The Godkann pill on /pending only opened a ConfirmationDialog demanding a
second Godkann, regardless of tier. The review row already states source,
title and risk and offers Detaljer, so for low/medium the pill now commits
directly; high risk keeps the dialog, whose warning sentence carries
information the row does not. Chat-side bulk approve is deferred: it needs
ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md.

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

* fix(reports): map inline momsperiod save errors through getErrorMessage

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

* fix(review): repair the dead login auto-forward and nine review findings

The big one: setActiveCompany ends with a cookie write that throws during
Server Component render (sealed cookie store), so the /select-company
auto-forward silently never fired; the write is now best-effort since the
cookie is write-only compat and the DB write is already verified.

Also: supplier-switch un-plants history-prefilled accounts so the new
supplier's own default applies; prefill routes through handleAccountChange
so konto default moms rides along; batch 'Ingen moms' books exempt instead
of the derived 25%; monthly VAT default tracks the actual 12th/17th filing
deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState,
gates on vat_number (the PUT would 400 without it), keeps keyboard focus
and announces errors; cost-account shape guard tightened to P&L accounts;
attn tone on the new warning lines.

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

* ci: retrigger workflows; the Actions outage swallowed the rebase push event

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

* ci: retrigger after outage (events dropped, not delayed)

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

* ci: retrigger after GitHub Actions recovery

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

* fix(review): address CodeRabbit and compliance-bot findings

Direct commit now prunes the op from the bulk selection (a stale id kept
inflating the bulk bar and rode into bulk-commit) and the detail-panel
Godkann gets the same risk gate as the row pill. The automatic account
fill in the supplier-invoice form is requested, not applied inline: the
applying effect waits for both the BAS chart and the request with fresh
closures, so a fill can no longer land before the chart and leave a
VAT-free konto on the 25% row default. Test dates use local-time
constructors (ISO strings parse as UTC midnight and shift a day in
negative-offset timezones). Stale ML 11 kap citation dropped from a
comment.

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

* ci: retrigger; push event dropped again

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 10:16:23 +02:00
Jakob Wennberg 63d520719a fix(import): treat a voucher-less SIE file as a no-op, not a failed migration (#1445)
* fix(import): treat a voucher-less SIE file as a no-op, not a failed migration

A Fortnox migration aborted with the generic "Något gick fel. Försök
igen." when the current fiscal year had nothing booked yet: Fortnox
exports an empty SIE file for such a year, the finalizer's 0-entry
safety net flipped it to 'failed', and the wizard stopped before the
customer/supplier/invoice phase ever ran.

Three layered fixes:

- finalizeImportRecord only downgrades a 0-entry run to 'failed' when
  the file actually contained vouchers (parsed count via the
  documentation object). A file with no vouchers completes as a no-op
  with an explanatory warning; the mapping-fix retry loop the downgrade
  exists for (Lookma case) is unchanged.
- The migration wizard no longer routes messages that are already
  user-facing Swedish (server envelopes, ImportResult.errors) through
  getErrorMessage's Swedish-pattern heuristic, which swallowed
  unrecognized sentences into the generic fallback.
- The heuristic itself learns the import-error family
  (verifikation/importera) so other surfaces rethrowing engine
  messages keep the real reason too.

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

* fix(import): cross-check raw #VER before accepting a 0-voucher file as empty

The parsed voucher count alone cannot prove a fiscal year was empty: a
field-separator or encoding mismatch can swallow every #VER block with
only a warning-severity parse issue, and executeSIEImport does not fail
on those. Only a raw content check proves the file never declared any
vouchers. Addresses the truncation/corruption finding from the Swedish
compliance review on #1445.

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

* docs: record the raw #VER safeguard in the empty-SIE-file decision entry

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-07 09:45:29 +02:00
Jakob Wennberg d7320a6c87 feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices (#1426)
* feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices

A kassakvitto has no due date, no OCR reference, no invoice number and no
bankgiro: it is already paid. The rail rendered all four as empty boxes
anyway, so a perfectly extracted receipt looked like a failed extraction.
The date was mislabelled too: on a receipt the document date is the
purchase date, not a "Fakturadatum".

When extracted_data.documentKind is 'receipt' the rail now labels the date
"Inköpsdatum" and folds the five invoice-only fields behind a quiet "Visa
fakturafält (N)" link.

Two rules keep it safe when the classification is wrong: a field holding a
value is never hidden (a hybrid restaurangnota with an invoice number
still shows it), and the fold is one click from being undone.

Logic lives in lib/documents/inbox-field-visibility.ts rather than the
component: this repo has no jsdom or testing-library, so display rules are
only testable as a React-free module.

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

* fix(invoice-inbox): reset the invoice-field fold when switching documents

Review catch (PR Agent on #1426): showAllFields was component state with no
per-item reset, and EditableFieldsList stays mounted across selections in
the rail. Expanding "Visa fakturafält" on one receipt therefore left the
invoice-only fields open on the next document, which reads as if that one
carried them too.

Reset alongside drafts and edit provenance in the existing itemId effect,
and moved the declaration up next to the other state so it is defined
above its first use rather than relying on hoisting.

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-05 20:26:16 +02:00
Jakob Wennberg 0449b1d0ea feat(settings): WhatsApp brand mark on the WhatsApp settings page (#1418)
The page had no visual signal that it configures a third-party channel,
so it read like any other Accounted setting. Adds an inline-SVG WhatsApp
mark next to the section title and on the button that opens WhatsApp
(replacing the generic lucide chat bubble, which was standing in for a
logo it is not).

Inline SVG rather than a bundled asset: no network request, scales, and
survives the CSP. SettingsSectionHeader gains an optional `mark` slot;
every other settings tab is untouched and stays mark-less on purpose, so
the rail does not turn into a sticker album. The green is the one place
brand colour appears in settings, which matches the design rule that
colour belongs to actors rather than chrome.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:30:13 +02:00
Jakob Wennberg d1c411ad6f feat(invoice-inbox): surface WhatsApp chat context in booking flows (#1339)
* feat(invoice-inbox): surface WhatsApp chat context in booking flows

The WhatsApp intake bot writes verified human answers (photo caption,
representation deltagare + syfte, sender note, open-question state) to
invoice_inbox_items.channel_context. This makes the in-app booking flows
READ it:

- New core renderer lib/documents/channel-context-notes.ts: deterministic
  compact Swedish line ("Representation: Anna Berg (Volvo), Jakob W ·
  Syfte: uppföljning av avtal"), capped at 220 chars by dropping whole
  participant names ("… och N till"), never mid-name. Representation
  first, then user_note; caption only when nothing else exists.
- FieldsRail "Från WhatsApp" block in InvoiceInboxWorkspace: caption,
  deltagare, syfte, anteckning rows plus an ochre AttnLine when a chat
  question expired unanswered (pending_question.status = moved_to_app).
- Notes threading: book-direct and convert default their notes to the
  rendered string server-side when the request carries none (a supplied
  value always wins); BookDirectlyDialog prefills its notes input with
  the same string so the user can edit it before it lands. Bulk-book
  (categorize-core) joins the shared batch note with the per-item
  rendered context so the representation trail survives batch booking.
- Inbox list: whatsapp rows get a chat icon and a quiet "Fråga obesvarad"
  badge for moved_to_app items. No worklist count change: unresolved
  whatsapp items are already counted by countInboxDocuments.
- sv/en strings for every new key; renderer + route + bulk tests.

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

* fix(invoice-inbox): honor cleared notes and keep unreviewed captions out of verifikat

Two adversarial-review findings on the WhatsApp surfacing flows, both about
text that lands on an immutable verifikat.

1. A cleared note was silently re-applied. BookDirectlyDialog prefills the
   rendered chat context, and the dialog sent `notes.trim() || undefined`
   while book-direct and convert defaulted from channel_context on any falsy
   value. A user who read the prefill, disagreed and deleted it therefore got
   it written back onto a posted entry, removable only through a formal
   rättelse. Both code comments claimed "an edited value always wins", which
   was false for exactly that edit. Now PRESENCE of the field decides: the
   dialog always submits `notes` (empty string included) and the routes only
   default when the field is absent from the request (MCP, older clients).
   The Zod `.optional()` carrying that distinction is documented at the
   schema so it is not "tidied" into a `.default('')` later.

2. The photo caption was auto-burned into verifikat text with no review.
   renderChannelContextNotes fell back to the raw caption, and bulk-book
   appended the result per item with no per-item notes field at all (the MCP
   approval preview deliberately shows no per-item PII either), so unreviewed
   chat text reached a WORM record nobody had seen. The renderer now takes
   { includeCaption } and leaves the caption out by DEFAULT: representation
   answers and user_note are replies to a question the bot asked, the caption
   is not. Only the Bokför direkt prefill opts in, where the user reads the
   string in an editable field before booking.

Tests: cleared-notes and whitespace-cleared on book-direct, cleared-notes on
convert, caption-never-defaulted on both routes, caption-not-threaded in
bulk-book, and the renderer's opt-in. The book-direct cleared-notes tests
were mutation-checked (restoring the truthiness fallback fails them).

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

* fix(reports): export the chat answers behind a verifikat in the full archive

The verifikat line caps the representation trail at 220 chars and drops whole
participant names ("… och N till"); the complete list (deltagare, syfte,
raw_answer) exists only in invoice_inbox_items.channel_context. That table was
in ARCHIVE_EXCLUDED_TABLES with a rationale predating channel_context ("inbox
workflow state"), so a company leaving Accounted and keeping the full-archive
export as its BFL 7-year record kept an incomplete deltagare documentation for
its representation deductions.

Dumped as a column PROJECTION, not the whole row: the new
MasterDataTableSpec.columns narrows the select to the underlag provenance
(document, matched transaction, created verifikat / leverantörsfaktura) plus
channel_context, so the answers are tied to what was booked from them while
the inbox workflow state (email bodies, OCR output, error messages) stays out
of the archive. The documents themselves remain in dokument/.

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:54:30 +02:00
Jakob Wennberg 398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:47:08 +02:00
Jakob Wennberg 3b3adf96c7 feat(invoice-inbox): receipt-aware extraction + OCR pipeline fixes (#1331)
Receipts and invoices were extracted through one invoice-shaped prompt
with no document classification. The extractor now also returns
documentKind, payment method (+ card last4), purchaseTime,
merchantCategory and legibility, validated with .catch(null) so a
hallucinated label degrades to unknown instead of sinking the parse.
The FieldsRail shows type and payment method above the editable fields.

Pipeline fixes, all verified against real failure paths:
- PDFs >3 pages: extract from a pdf-lib slice of the first 3 pages
  instead of skipping entirely (issue #553 gate); truncation recorded
  in extracted_data.pages and shown in the UI.
- Oversized images (>4 MB, over Bedrock's 5 MB cap): downscale to
  <=2000px JPEG via sharp before base64, instead of erroring to an
  empty result.
- HEIC/HEIF: attempt sharp transcode to JPEG; when libvips lacks HEIF
  (prebuilt binaries), fall through to today's behavior but show an
  explicit hint instead of silently blank fields.
- Oresavrundning: prompt rule + totals.roundingAmount so receipt totals
  reconcile with subtotal+VAT for exact-amount transaction matching.
- retry-extraction overwrites extracted_data wholesale: a confirm now
  guards against silently destroying manual field edits.

Deliberately NOT added: retry-on-transient-Bedrock-error; the SDK
already retries twice by default (maxRetries=2).

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:20:40 +02:00