b07a4a4bca5161d43d96ce465cf90124dfd348d4
145 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3ec76d39db |
fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670 |
||
|
|
93541d7186 |
fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges (#1633)
* fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges Follow-up batch to #1629: the six documented deferred items from dev_docs/loading_states_analysis.md, in the same vocabulary (first-load-only takeovers, background reconcile behind mounted content, row/button-level pending, sequence guards). - Invoice detail pages: kundfaktura and leverantorsfaktura detail no longer blank the whole page for one-field changes. fetchInvoice shows the blocking spinner/skeleton only before the first paint (or when the pager steps to a different invoice); Bokfor / status / finalize / payment / send / Attestera / Markera betald / kreditera refetch behind the mounted page, the acting button shows a spinner-in-button, and the handlers await the refetch so pending covers until the content reflects the new state. The supplier detail's single isProcessing boolean became processingAction so the spinner lands on the clicked button only. (The leverantorsfakturor LIST try/catch/res.ok item was already fixed by #1629.) - useDestructiveConfirm: confirm(opts, action?) can now carry the destructive operation, so the dialog's existing isLoading spinner actually shows while it runs, dismissal is blocked meanwhile, and confirm resolves false if the action throws. Adopted at the /transactions row delete and the supplier- invoice detail delete (which previously permitted duplicate DELETEs with zero feedback). - Batch parallelization: new lib/concurrency.ts mapWithConcurrency (bounded worker pool, order-preserving, tested). /transactions batch categorize / ignore / delete run per-row requests 5 at a time instead of strictly sequentially; the bulkbar counter ticks per completed row. - Toast-spam reduction: batch categorize rows run silent (exit animation, count decrement and state patch stay; no per-row Bokford or generic failure toast) and ONE aggregate toast reports "N bokforda[, M misslyckades]" with a single Angra alla action that pools the same /uncategorize endpoint over every booked row (per-row undo is feasible today, so the aggregate is too). Interactive escalations (SI/CI match suggestions, duplicate warning, activate-account) deliberately keep their dialogs. - Underlag row-click flash: InvoiceInboxWorkspace handleSelect seeds the detail pane synchronously from the clicked list row and starts the document load in parallel with the detail GET (which hydrates on arrival), so a row click never flashes the onboarding/empty state, and a stale-response guard keeps a slow fetch from overwriting a newer selection. - #1629 round-2 edges: /pending holds the loading state when a fetch for a not-yet-loaded tab FAILS (never renders the previous tab's rows under the new tab's header, and never fakes an empty state); /transactions clears transactions/skvRows (+ count/paging) and bumps both fetch sequences on company switch, and loadSkvRows got the same sequence-guard pattern as fetchTransactions. Gates: full vitest suite green (14772 passed), tsc byte-identical to the origin/main baseline (stash-diffed), eslint 0 errors on touched files (warnings identical to baseline), check:guards green, package-lock untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): harden action feedback against stale responses and failures Address the seven CodeRabbit findings on #1633: - invoices/[id] + supplier-invoices/[id]: latest-request guard in fetchInvoice (sequence token) so a mutation refresh overlapping pager navigation can never commit invoice A's state under invoice B's URL; the deferred related-document writes are guarded too - supplier-invoices/[id]: try/catch/finally in approve/book/mark-paid/ credit/uncredit so a rejected fetch()/json() clears processingAction instead of leaving every invoice action disabled until reload - transactions: extend the skattekonto sequence guard to the connection-status write so a status response started under the previous company cannot flip the reconnect banner for the new one - transactions: runCategorize resolves { ok, journalEntryId } so the batch aggregate counts a 200-with-null-journal-entry booking (flag flip) as success instead of narrating it as misslyckades; Angra alla only targets rows with an actual verifikat, since the storno endpoint rejects rows without one - transactions: shared undoneIdsRef lets "Angra alla" cancel a pending finishBooking state patch; a fresh booking clears its row's entry so re-booked rows still get their delayed patch - InvoiceInboxWorkspace: monotonic request tokens for the detail and document reads so a same-item reload cannot resolve out of order and paint a stale snapshot or document URL - messages: ICU plural for the success part of both partial batch descriptions in sv and en (1 bokford, not 1 bokforda) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f8db38f989 |
fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |