fbe4e1873035638615abaf8bfd963dc9fc6afef9
567
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fbe4e18730 |
feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link gnubok_categorize_transaction only spoke a 19-category enum mapping to 21 hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were unreachable from the agent surface even when active in the chart. - add account_override to gnubok_categorize_transaction with v1 REST semantics via a shared helper (lib/bookkeeping/account-override.ts): business-side replacement, class-2 auto-VAT drop with the 2610-2649 moms-line exception, plus a same-account degenerate guard; validated at staging and re-validated at commit - align the gnubok_create_voucher staging gate with the engine's seeding semantics: BAS 2026 accounts merely absent from the chart pass (the engine backfills them at commit) and the preview lists will_activate_accounts with BAS-name fallback; non-BAS unknown and inactive accounts still rejected - stop suggest_categories silently dropping mapping rules whose account is outside the fixed category maps; they surface with the rule's own account and an explanatory match_reason - correct the create_account next-step hint (categorize could never use the new account before; now true via account_override) - point the settings "Kontoplan (BAS)" link at /chart-of-accounts and redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in #850; the deep link never worked after the #854 merge collision) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): address review findings on account_override - commit executor rejects a present-but-malformed stored account_override loudly instead of degrading to the category default (CodeRabbit major; the approver approved a preview showing the override account); with commitPendingOperation regression tests - accountToCategory returns null for unknown income accounts so custom income accounts get the same diagnostic as expenses (CodeRabbit minor), with income + reason-accumulation tests (CodeRabbit nit) - pin the class-2 VAT-drop balance invariant with a test through buildTransactionEntryLines (Swedish compliance review: gross booking, never an unbalanced net + missing VAT leg) - account_override description asks the agent to state the actual affärshändelse in notes when overriding (BFL 5 kap description concern) - eventBus.clear() in the two new test suites (CodeRabbit minor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent Round-2 Swedish compliance finding: the class-2 VAT drop did not cover margin-scheme (VMB) accounts in class 3/4, which are the override's flagship use case, so a forgotten vat_treatment attached the category default standard_25 and booked an ingående-moms deduction on a transaction where input VAT is not deductible (ML 2023:200). applyAccountOverride now takes explicit VAT intent (vat_treatment or vat_amount present) and books GROSS with no auto-VAT line without it: forgetting the flag under-deducts (lawful), never over-deducts. Both call sites (MCP staging preview, commit core) derive the flag the same way; the tool description states the enforced behavior. Deliberate divergence from v1 REST recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: move stray decision-log entry to the root DECISIONS.md The round-2 entry was appended from the wrong working directory and landed as lib/bookkeeping/__tests__/DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4bb0655e4a |
feat(salary): öresavrundning of net pay to whole kronor (#1609)
* feat(salary): öresavrundning of net pay to whole kronor Some banks reject salary payment files whose amounts carry öre. New company_settings.salary_net_rounding toggle (off by default): the engine rounds each net payout up to the next whole krona, never down, and emits a derived oresavrundning line item (semesterersattning pattern) that debits 3740 Öres- och kronutjämning so the salary entry stays balanced. Gross, tax and avgifter are untouched, so AGI/KU are unaffected. Payment files (pain.001 + Bankgirot LB) get whole-krona amounts via the rounded net_salary. Toggle in salary settings; payslip and run detail show the line item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): keep employer cost on the shared definition; block manual rounding lines Skeptic findings on the öresavrundning commit: (1) the engine included netRounding in totalEmployerCost while payslip summary, KPI cards and lönejournal recompute the figure from stored columns, printing two different totals on the same payslip; employer cost now stays on the shared definition and the öre cost is carried by the 3740 ledger line. (2) 'oresavrundning' is excluded from the line-item create/update schemas: it is the only item type the booking keeps out of the gross reconciliation, so a manually created row would structurally unbalance the salary verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): add the item_type CHECK as NOT VALID, validate separately Compliance-swarm finding (SOC 2 CC8.1): the CHECK re-add scanned salary_line_items under the ADD's ACCESS EXCLUSIVE lock. Split per the house pattern (DECISIONS.md 2026-07-13): 20260813143000 re-adds the constraint NOT VALID, new 20260813143001 validates it under SHARE UPDATE EXCLUSIVE in its own transaction. The list is a strict superset of the previous CHECK, so validation cannot fail. Both files are branch-only, so editing in place is within the never-modify-shipped rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.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> |
||
|
|
08440fed94 |
feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat A first-class Fortnox/SIE migrator path: after SIE import plus bank connect or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or suggestion-matched (0.75-0.89, persisted for review) against the imported verifikat, with a guided review surface, instead of landing as anonymous "Att bokfora" rows. Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account pooling); widen payment_match_log action CHECK with linked_to_existing_voucher (silently unlogged since March). Phase 1: potential_journal_entry_id/method/confidence on transactions with CHECK + invalidation triggers; persistSuggestions in runReconciliation; sweep after bank CSV import with SIE overlap (suppressing auto-categorization); sweep summaries stamped on bank_connections and bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with per-pair server-side revalidation (voucher consumption + bank-leg amount and direction). Phase 2: "Granska forslag" review tab on Transactions with chunked bulk confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode, mutually exclusive with dry_run), attn line, pre-migration row marker. Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant of the account-picker #917 nudge, sweep outcome on the onboarding checklist bank step. Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9 and persist the review band instead of auto-committing fuzzy matches. Migrations already applied to staging under the same versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): resolve PR review findings in one pass Swedish accounting review (both previously-deferred holes closed): - runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus event alone lands in the 30-day event_log and is not an audit record. - The three match-route storno-conflict branches detach reconciliation links via unlinkReconciliation instead of storno-reversing the linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). - Historical gap quantified on prod (read-only, recorded in DECISIONS): 762 unlogged manual links across 52 companies since 2026-03-23. CodeRabbit: - confirm-suggestions route: maxDuration 300 for full 500-item batches. - AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the async gap-fill probe cannot override an explicit choice. - enable-banking post-backfill sweep: persistSuggestions so the review band is not dropped. - bank-file execute: sie_sweep stamp errors are logged, not swallowed. - ImportResultStep: sandbox keeps the CSV CTA (file import works there). - payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan under ACCESS EXCLUSIVE. - logMatchEvent calls awaited (serverless can freeze unawaited work). - DECISIONS.md stale version reference annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): defer reconciliation-link detach until the match commits Round-2 review findings: - CodeRabbit: the eager unlinkReconciliation call could orphan a transaction if the match flow failed after it. All three match routes now persist NOTHING up front: the final transaction update overwrites journal_entry_id and clears reconciliation_method in the same write, so any failure in between leaves the existing link intact. The release is logged as 'unmatched' after the commit. - Swedish review: the auto_suggested logMatchEvent in runReconciliation is now awaited like every other audit write. - DECISIONS entry split into compliance/CodeRabbit lines and updated to describe the deferred detach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner The conditional spreads introduced with the deferred detach pushed the scanner's unresolvable-expression count past its ceiling (380 > 378). reconciliation_method: null is correct unconditionally on a confirmed invoice/supplier match (null is already the value on every row that was not reconciliation-linked), so the payloads become plain literals the guard can verify. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
05380ddf54 |
feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d02fd82191 |
feat(vat): add per-account declaration treatments (#1588)
Closes #1457 |
||
|
|
ce6efdb3dc |
refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)
* refactor(pending): one pending-op-owned preview for chat, /pending and flow views A staged pending_operation was rendered three separate ways: the /pending page's OperationPreview switch (8 specialized renderers keyed on operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack that mapped stored operation_types onto 'gnubok_'-prefixed tool names on hydration. This is the weakest seam ahead of flow-run views (plan seam 8.3): every new operation type had to be taught to render in two places and silently degraded in the third. Now there is one owner: - components/pending-operations/OperationPreview.tsx: the /pending renderers moved verbatim, dispatched on operation_type, consumed by /pending, ApprovalCard and future flow-run views. - components/pending-operations/vocabulary.ts: operation labels, single-action warnings and the one canonical rejection-category list (ApprovalCard's copy was byte-identical and is deleted). - lib/pending-operations/tool-name.ts: the single translation point between bare operation_types and 'gnubok_' tool names, with tests. toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only recognized 4 tool names, so a hydrated card for any other operation type (attach_document_to_transaction, match_transaction_invoice, ...) silently fell back to a raw generic preview. Hydration now passes the stored operation_type straight through attachStagedOperations to the card, and live streamed cards derive it from the event's tool name, so every operation type keeps its specialized preview on resume. Per-surface chrome (list row on /pending vs inline chat card) is deliberately kept: only the preview + vocabulary were the duplicated seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop a stray hunt_title copy rename that rode along 'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was uncommitted working-tree state from another session, swept into the extraction commit by git add breadth. It is a product-naming call with no en.json counterpart and does not belong in this refactor; preserved in this branch's first commit if it turns out to be wanted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pending): carry params to chat previews; guard preview amounts CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders its DocumentViewButton from params.document_id, which neither chat path carried: the staged_operation stream event now includes the tool-use input (the same values the staging tool stored as pending_operations.params) and hydration selects the params column, so an attach-document card in chat shows its evidence button live and on resume. (2) InvoicePreview and CreateTransactionPreview cast amounts straight into formatCurrency; a payload without one rendered 'NaN kr'. They now share the same show-the-gap guard the legacy summary already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
0d3ba5268d |
fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3 The booking-time duplicate guard missed the most common bank-fee twin shapes: - G1: the sibling scan matched on the EXACT date only, so a duplicate import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was invisible. The scan now uses a +-3 day window with a deterministic ranking where exact-date candidates always outrank drifted ones (force=true re-detection stays bound to the reviewed candidate). - G2: booked-ness required transactions.journal_entry_id, so bulk-booked (transaction_voucher_links) and multi-allocated (invoice_payments / supplier_invoice_payments) siblings read as unbooked. The scan now batch-fetches the anchor rows and resolves the verifikat via getPrimaryJournalEntryId (is_transaction_booked semantics). - G3: the ledger scan excluded every voucher linked to any transaction, so a voucher booked from a date-drifted duplicate row escaped BOTH halves and the booking proceeded with no warning. A voucher whose linking transaction itself matches the target (same ore in the same currency, compatible cash account, date in the window) is now returned as the twin with transaction_id set. All candidate picks keep explicit total-order tiebreakers so a force re-detect returns the same candidate the user reviewed, and the SEK-or-null amount contract is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog The duplicate dialog hid its match action for sibling-transaction candidates (canMatch required transaction_id === null), so the user who most needed steering saw only 'Bokfor anda'. manualLink explicitly allows N:1 links, so the match action is now offered for both candidate kinds. Sibling candidates get question-form body copy ('vill du matcha mot verifikatet i stallet?') and an additional 'Ignorera transaktionen' action via the existing POST /api/transactions/[id]/ignore, which is the correct resolution when the row itself is a duplicate import (matching would double-count the bank side, booking the ledger side). Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a destructive toast with no way forward: - the counterparty-template branch of handleQuickReviewConfirm now sets the shared duplicateWarning state exactly like runCategorize, with the force retry bound to the reviewed candidate's voucher - BankReconciliationView's quick-book now opens the same dialog, with match/ignore refreshing the reconciliation lists New sv/en strings: dialog_duplicate_body_sibling, dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level parity tests pin the 409 routing and the dialog affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): duplicate guard on the bulk-book samlingsverifikation path /api/transactions/bulk-book never called detectBookingDuplicate, so a batch containing an already-booked twin minted a second verifikat with no warning. The route now runs the shared per-tx guard before the RPC, with intra-batch exclusions (the other selected txs are distinct events the user picked, and the link-existing target voucher is the batch's own destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the candidate and the flagged tx id. BulkBookDialog routes the 409 into DuplicateBookingDialog for review (view voucher / cancel / book anyway) instead of a dead-end toast; 'Bokfor anda' re-runs the batch with force=true. On force the route re-detects and records each dismissed candidate as BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2 kap 8), parity with the /categorize bypass. Detection failures stay fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions) bypasses this route and remains unguarded; guarding inside the RPC needs a migration and is out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): gate the duplicate-dialog ignore hint on the action being present The sibling body copy mentioned ignoring the row, but two render sites (the manual booking form and the bulk dialog) show sibling candidates without the ignore action. The guidance now lives in a separate dialog_duplicate_ignore_hint string rendered only when the Ignorera button itself renders, so copy never points at a button that is not there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1b829883ae |
feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)
* feat(reconciliation): accept confidence_threshold on the bank run route Mirror the v1 route: RunReconciliationSchema gains an optional confidence_threshold (0..1) that passes through to runReconciliation as the server-side floor on the apply path. The UI sends 0.85 with a strong-only apply so a pair the fresh re-run scores lower is skipped instead of committed; omitting it keeps the legacy behavior where every selected pair applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): promote the bulk match flow and bridge it from the inbox The dry-run preview with pre-ticked strong matches existed but was never found: users matched whole migrations row by row. Three discoverability changes, no engine changes: - Bankavstamning: an attention line above the toolbar while unmatched transactions exist and no preview has run, with Forhandsgranska promoted to the filled variant. When every ticked preview pair is a strong match (>= 0.85) the apply button relabels to 'Matcha X starka traffar' and the apply sends confidence_threshold 0.85; mixed selections keep the plain label and omit the floor so manually ticked weaker pairs still apply. - Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the preview once, only after appliedDates is set and not while datesDirty, so it can never cover a different window than the on-screen lists. - Transactions inbox: with >= 5 unbooked bank rows visible, an attention line links to the reconciliation with autorun (static text + count, no probe; the preview is the honest source of how many actually match). The review step stays: autorun lands on the preview table, one click from apply, and the server intersection guard is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1eebb75269 |
feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.
New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.
UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.
New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0643316ac8 |
feat(import): show SIE import history with undo on the import tab (#1574)
* feat(import): show SIE import history with undo on the import tab The list route (GET /api/import/sie) and the undo route (DELETE /api/import/sie/[id]/undo) both existed, but no UI ever called the list: once the post-import result screen was gone, past imports could not be seen or undone. Add a fold-open 'Tidigare SIE-importer' row on the Importera tab (same expanded pattern as the cloud-backup row) that lazy-loads a history table: filename, date, fiscal year, voucher count, status, and an undo button on completed rows. Undo confirms through DestructiveConfirmDialog (voucher count, IB cleared, documents detached but kept; plus a voucher-gap warning for large imports), keeps the dialog open for the long-running DELETE, and refetches on completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(import): cover the SIE list and undo routes The base list route had no test file (its siblings all do) and the undo route was only covered indirectly. Add route tests through the real withRouteContext wrapper: 401, the { data, count, limit, offset } shape with company scoping and range math, the status filter, and the Swedish 500 path for the list; 401, 403 viewer, the { success, deletedEntries } passthrough, and the SIE_UNDO_FAILED envelope (reason in details) for undo, with undoSIEImport mocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e1f13f870a |
feat(import): warn about already-imported rows in the bank-file wizard (#1567)
* fix(transactions): paginate the ingest dedup maps past the 1000-row cap
buildExistingTransactionMaps issued un-paginated selects for the booked and
unbooked dedup maps, so PostgREST silently truncated each at 1000 rows: a
re-import over a wide date range in an active company deduped against a
partial map and inserted everything past the cap as duplicates. Both queries
now go through fetchAllRows with a stable .order('id') for range paging.
Also exports the function and its types for the upcoming read-only duplicate
preview, which must share the exact stored-row universe execute-side ingest
dedups against.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(import): add read-only duplicate preview endpoint for bank files
New POST /api/import/bank-file/check-duplicates (withRouteContext + Zod,
transactions capped at 20000) computes external_ids with the exact
generateExternalId(tx, format, index) derivation execute uses and runs
previewDuplicates: Layer-1 id collisions plus the Layer-2 text bridge with
counting semantics and the currency guard, against the same stored-row maps
ingest builds (buildExistingTransactionMaps). The result is advisory; execute
stays authoritative and mirrors/settlement-account guards are documented
preview/execute differences.
A dedicated endpoint because the generic_csv path re-parses client-side and
never re-hits /parse. Also removes the dead existing_transaction_count field
from the parse response (a raw date-range count consumed by nothing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(import): surface duplicate rows in the bank-file import wizard
Overlapping bank imports used to dedup silently: the wizard promised
'Importera N transaktioner', ingest skipped the twins, and the user saw fewer
rows than parsed with zero explanation. The wizard now calls check-duplicates
after a successful parse AND inside handleColumnMappingConfirm (the
generic_csv path never re-hits parse), and:
- BankFilePreviewStep: warning card in the AlertTriangle pattern ('{count}
rader finns redan', skipped automatically) plus a 'Finns redan' badge on
flagged rows in the 50-row table
- BankFileConfirmStep: repeats the summary card (generic path skips preview)
and the CTA counts 'Importera {parsed - duplicates} transaktioner'
- BankFileResultStep: renders result.duplicates when > 0, closing the loop
ingest.ts documents as unrendered
Execute semantics unchanged: all rows are sent, ingest skips; the preview is
advisory and never promises an exact final number. New strings in both
messages/sv.json and messages/en.json next to the import_psd2 anchors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7ccaab7a08 |
fix(agent): contain the floating assistant panel on open and resize (#1575)
A persisted float rect saved at the viewport edge, or on a larger monitor, passes clampFloatRect (which only keeps 48px reachable so a live drag may deliberately hang off an edge) and renders the panel as a 48px sliver on every open. Add containFloatRect, which snaps the whole window inside the viewport, and an AgentSheet effect that validates the persisted rect on sheet mount and on viewport resize and persists the corrected position. The rect is read through a ref so drag commits do not re-trigger containment: parking the window half off-screen still works within a session. The live drag path and clampFloatRect are unchanged, and containFloatRect's output is always a fixpoint of clampFloatRect, so the render clamp stays a no-op. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f8507d38ae |
fix(settings): land Medlemmar clicks on the members section (#1566)
The user-menu link pointed at /settings/team, but in-app navigation is intercepted by the settings modal, whose section map has no team entry: unknown sections fall back to Företag, leaving the user to scroll and find Medlemmar themselves. Link to /settings/company#members instead, and scroll the members section into view when it mounts (ref callback, since the content mounts after the settings fetch). The hash is cleared after scrolling so tab-switching back to Företag stays put. 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> |
||
|
|
abff8d90f6 |
refactor(ui): validation as errors on attempt, help text behind the ? (#1561)
The founder flagged the app as bloated with standing instructional text. Ny verifikation: the what's-missing lines (Ange en beskrivning, Minst två rader...) rendered from the first frame because their only gate was form validity, which an empty form fails: instructions dressed as validation. The submit buttons now stay enabled and an attempt on an incomplete form is what surfaces the lines, in destructive red, per the error-on-submit idiom. The Enter-to-advance flow keeps the old completeness predicate so navigation is untouched. Matcha mot befintlig verifikation: the two-sentence explainer moved behind a ? (HelpPopover, convention 7), the N:1 note tightened, the Visa även matchade switch became a quiet link (switches are settings idiom), and Stark träff, the normal auto-selected case, renders as muted text instead of a chip (chips mark exceptions, convention 5). Deleted always-visible paraphrase lines and their orphaned keys: items_card_description, picker_description, references_subtitle, sort_stack_hint, dimensions hints, document_help, and the dead fill_balance_hint key that had no render site at all. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1e9f245f7c |
refactor(agent): keep the assistant in the nav, FAB and underlag flow only (#1557)
The founder wants the scattered per-page assistant buttons gone: the assistant is reachable from the nav and the floating tab everywhere, so in-page duplicates were noise. Removed the AgentSparkleButton call sites (year-end, verifikat detail, supplier invoice detail, invoice editor) and the now-orphaned component, the soft hand-off link in the Ny verifikat modal (plus its i18n keys), and the transaction-row overflow item. Kept: nav entry, floating tab, the Dokumentinkorg flow, and the sanctioned "Skapa med assistent" split-button mode on /bookkeeping (design.md convention 14). The command palette's hand-off entries hardcoded the agent name "Anna"; they now use the identity from AgentSheetProvider like every other affordance, and hide until agent onboarding is done (same gate as the FAB). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5453f11330 |
fix(design): keep sortable table headers uppercase (#1558)
The sortable headers on /bookkeeping and /invoices wrap their label in a button, and preflight's text-transform: none on buttons swallowed the uppercase from TH_CLASS, so sorted lists showed sentence-case heads next to every other table's uppercase idiom. Re-apply uppercase on the sort control itself. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
8f1b1fb5cd |
feat(ui): company monogram in user menu + mobile web touch polish (#1531)
* feat(ui): replace generic building icon with company monogram in user menu The company row and switcher flyout in the sidebar user menu showed lucide Building2 for every company. Render the company's initial in a small rounded square instead (square = company, circle = person), so each company gets a mark of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mobile): touch behavior polish for the mobile web experience - kill -webkit-tap-highlight-color flash; touch-action: manipulation on interactive elements (no double-tap-to-zoom wait); user-select: none on buttons (long-press no longer enters text selection) - overscroll-behavior-y: contain on html/body: pull-to-refresh no longer hijacks list scrolling, inner scrollers stop chaining to the document (contain, not none, so iOS rubber-banding survives) - 16px font-size floor for form fields on coarse pointers: iOS Safari stops zooming into focused inputs; desktop keeps text-sm - min-h-screen -> min-h-dvh everywhere: correct height under collapsing mobile browser chrome, identical on desktop - active: variants mirror hover: on Button: Tailwind 4 gates hover: behind (hover: hover), so touch devices previously got zero pointer feedback - theme-color now tracks the app: default was a leftover blue #304D83; SSR emits white and ThemeColorSync mirrors the computed --background into the meta tag across dark mode and palette switches Hover-stuck-after-tap and viewport-fit/safe-area were already covered (Tailwind 4 hover gating; existing viewportFit: cover + safe-area utilities). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log monogram and overscroll decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
b9bf60234d |
feat(transactions): filter /transactions by rakenskapsar and kvartal (#1545)
* feat(transactions): filter /transactions by rakenskapsar and kvartal User request: booking a specific period (including brutet rakenskapsar, e.g. July-June) meant scrolling past every other year's transactions. - New FyPicker chip in the toolbar scopes both the inbox and history views to a fiscal year; quarter chips (Q1-Q4, fiscal-year aligned) appear once a year is selected. Clicking the active quarter widens back to the year. - Bounds are pushed into the Supabase queries (window, pending backlog, badge count, load-more) so pagination and the Att bokfora count stay consistent with the visible list; skattekonto rows are bounded client-side. - Scope persists under a page-local localStorage key, deliberately separate from the shared report scope so a year picked on a report page never silently hides pending inbox rows. - lib/transactions/period-filter.ts derives quarter bounds from fiscal period dates (handles brutet, shortened and extended years); unit tested. - FyPicker gains an optional storageKeyPrefix prop; default unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): never hide pending rows behind the period filter Swedish accounting review on PR #1545: scoping the pending-backlog fetch and badge count to the period made unbooked rows outside the selected year vanish from the inbox worklist (BFL 5 kap: pending affarshandelser must stay visible until booked). - Pending-backlog fetch and the DB pending count are unscoped again; only the history window pages server-side within the period. - The inbox applies the period client-side over the complete backlog; the tab badge counts pending rows inside the scope. - When pending rows (bank or skattekonto) fall outside the scope, the footer says how many and offers Visa alla, which clears the filter and its persisted value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): guard list fetches against stale cross-scope responses CodeRabbit on PR #1545: - fetchTransactions/loadMoreTransactions now carry a fetch generation; a response applies only if no newer fetch (scope change, realtime refresh, load-more) started meanwhile, so a slow pre-filter request can no longer overwrite the active period scope's window, paging offsets, or loading skeleton. - FyPicker restore effect includes storageKeyPrefix in its deps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): label quarter chips as fiscal-year quarters Swedish accounting review note on PR #1545: Q1-Q4 follow the company's rakenskapsar, which on a brutet rakenskapsar differs from the calendar quarters that momsdeklaration periods use. Say so in the group's aria-label and hover title so the chips are not mistaken for VAT periods. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
45d7f1be4e |
feat(mileage): surface Körjournal in the nav behind a settings toggle (#1540)
* feat(mileage): surface Körjournal in the nav behind a settings toggle The /mileage page shipped hidden: the route works but no nav row points at it. Add company_settings.mileage_enabled (mirroring dimensions_enabled) with a switch in Fönster -> Bokföring, and show the Arbeta nav row when the toggle is on OR the company already has mileage_trips rows, the same hybrid gate as webshop orders, so trips created via API/MCP can never become invisible underlag. UI visibility only, never load-bearing for correctness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move mileage_enabled migration after already-applied 20260812153208 origin/main merged in 20260812153208 which prod has already applied; a new file sorting before it risks an out-of-order db push abort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
67febd5097 |
feat(ui): prev/next record navigation on detail pages (#1530)
* feat(ui): prev/next record navigation on detail pages Customer feedback: stepping between invoices in a reskontra (56 -> 57 -> 58) required going back to the list for every record. List pages now write their full ordered id array to sessionStorage when a row is opened (Accounted:list-context:<scope>:<companyId>), and the detail pages for kundfakturor, leverantorsfakturor, and verifikat show a compact prev/next pager (chevrons + 'n av m') next to the back control. ArrowLeft/ArrowRight step too, except while typing in a text field or while a dialog is open. Navigation uses router.replace so 'tillbaka' returns to the list in one step. Deep links and new tabs have no context: the pager hides and pages behave as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pager): overlay-aware arrow guard, notes-draft safety, context on Visa detaljer Review fixes on the detail-record pager: - The keyboard guard matched any mounted [role=dialog], so the agent sheet (which stays mounted display:none once opened) killed arrow paging for the rest of the tab session, while open dropdown menus did not block at all. The guard now mirrors the AgentSheet Esc selector (data-state="open" variants incl. alertdialog and radix menu/select/listbox content) and also yields while focus sits inside a dialog/menu/listbox container. Extracted as pure functions in lib/hooks/detail-pager-guards.ts so the rules are testable in the node test environment. - Arrow keys could unmount the verifikat page and destroy an unsaved notes draft once the textarea lost focus. useDetailPager and DetailPager now take a keyboard flag, and the verifikat page disables keyboard paging while editingNotes is active; the chevron buttons stay live. - The expanded-row Visa detaljer link in JournalEntryList navigated without writing the list context, producing stale pager snapshots; it now calls rememberListContext like the voucher link. - ListContext.listPath was written and strictly validated but never consumed: removed from the interface, all writers, and the read validation. Reads stay tolerant of extra properties so contexts stored by older builds still parse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(pager): quiet wayfinding strip instead of buttons in the title cluster The pager sat between the back arrow and the H1, which read as a toolbar of three boxed buttons and made the title jump horizontally per record. All three detail pages now share the verifikat page's pattern: a muted back text-link on the left and the pager right-aligned on the same quiet row. The pager itself drops to 16px glyphs, muted ink, and hides when the list context holds a single record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dea5e31756 |
feat(skattekonto): bulk Bokför + inline single-row booking (fewer clicks) (#1535)
* feat(skattekonto): bulk Bokför and inline single-row booking
Booking a year of skattekonto events took 6 clicks per row (list, Bokför,
review page, Bokför, confirm, navigate back), even for a +1 kr
intäktsränta row.
- GET /skattekonto/transaktioner now attaches a deterministic
booking_suggestion per unbooked row (one hoisted skattekonto_rules +
entity_type fetch via the new attachBookingSuggestions), shown as muted
text on the inbox row ('Bokförs mot 8314 ...').
- New POST /skattekonto/transaktioner/bokfor-batch (Zod, max 200 ids):
sequential draft+commit per row server-side (no orphan drafts), per-row
results, never aborts on a row failure. Commit attribution: bulk_accept
for real batches, user_accept for the one-row inline flow. A failed
commit keeps the linked draft (degrades to the old review flow).
- Inbox: hover-reveal checkboxes on eligible SKV rows (suggestion
present, no duplicate hint, unbooked, genomförd), separate skvSelectedIds
set, bulkbar 'Bokför valda (N)' with ONE summary ConfirmationDialog
grouped by suggestion with per-group sums, chunked batchProgress, ONE
aggregate toast, local state patch with exit animation.
- Single-row: new SkattekontoBookDialog (dynamic import) replaces the
draft-then-window.location detour on both /transactions and /skattekonto;
'Öppna som utkast' keeps the old draft path via router.push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skattekonto): batch booking guards for unsettled rows, double-post race and locked periods
- reject status != booked rows in the batch flow with NOT_SETTLED before
any draft exists (server no longer trusts client eligibility); the
single-row draft endpoint keeps its behaviour
- make the journal_entry_id backlink a conditional claim (update where
journal_entry_id is null, select affected rows): zero affected rows maps
to ALREADY_BOOKED and commitEntry only runs after a won claim, so two
concurrent submissions can no longer double-post the same row
- detect the period-lock trigger signature in the batch catch and map it
to PERIOD_LOCKED with Swedish text instead of UNKNOWN with raw DB output
- SkattekontoBookDialog: rows with no matched rule no longer get the
guaranteed-422 draft CTA; they route to the existing match flow and to
manual verifikat creation in /bookkeeping
- pass an explicit skv_book_dialog.commit_warning key for the
direct-commit warning instead of ConfirmationDialog's hardcoded default
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7cf0e34434 |
feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
11995b1b0c |
feat(auth): make automatic logout an opt-in per-user setting (#1536)
* feat(auth): make automatic logout an opt-in per-user setting Session timeouts (30 min idle / 12 h absolute on hosted) now apply only to users who enable "Automatic logout" in Settings > Security. Default is off: sessions live for the full Supabase refresh-token lifetime, the behavior from before the 2026-07 session hardening. - user_preferences.auto_logout (migration, default false), toggled via the extended /api/user/preferences route - The opt-in is snapshotted into the signed timeout cookie at mint, so enforcement stays DB-read-free per request; the preferences route clears the cookie on change so a toggle takes effect immediately - Pre-toggle cookies are authentic-but-stale: re-minted preserving their timers, never routed down the tamper path, so the rollout does not log anyone out - NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for every user regardless of preference (emergency lever, also plumbed through the Docker image); self-hosted stays disabled by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): resolve PR #1536 review findings - Replace the spread upsert in /api/user/preferences with one literal payload per field: the phantom-column schema guard cannot resolve spread payloads (Unit tests 3/4 ceiling failure) - Map the preferences 500 through getErrorMessage so the user-facing text is Swedish (CodeRabbit) - fetchAutoLogoutPreference now returns null on a FAILED read instead of a fail-open false: callers skip minting so an unknown preference is never persisted into the year-long signed cookie, and the next request retries; failures log at error level, distinct from the normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): write multi-field preference updates as one atomic upsert A request carrying both hide_assistant_fab and auto_logout previously issued two sequential writes, so a failure of the second returned 500 after half the request had persisted (CodeRabbit, PR #1536). One literal upsert per accepted field combination keeps the write atomic and stays resolvable for the phantom-column schema guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c35b2547fb |
feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface webshop_orders (order/refund rows, financial-freeze trigger, member select/update RLS, no DELETE) + webshop_store_settings (per-store payment method -> account map), source_type 'webshop_order', multi-store index drop, customer_country, and a one-time woo cursor reset so the switch-over backfills and cross-marks existing feed rows. Tables classified in the full-archive export; pg-real coverage for RLS, freeze and CHECK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): core service (ingest, booking lines) upsertWebshopOrders: two-phase order/refund upsert with FX enrichment, legacy-feed cross-marking, frozen-row protection and field-wise jsonb comparisons (Postgres does not preserve object key order). Booking-line builder: per-rate VAT split with SIGNED buckets (discounts book as revenue reductions), refund mirroring, 3740 residual, per-store account prefill, and advisory export/EU + OSS warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): API routes for list, booking, invoicing and mapping Booking is draft -> atomic claim -> commit (conditional link-back closes the concurrent double-book race; a lost claim cancels the voucher-free draft). Legacy-feed guard honors transactions.is_ignored on both the book and create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an unnumbered draft with dominant-rate fallback and drift-safe unit prices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav /orders lists per-store orders with status tabs (server-side filters), exception chips and one action per row. Booking dialog prefills from the per-store payment-method mapping with an opt-in remember; invoice dialog converts to a draft kundfaktura. The Order nav item renders only for companies with an active WooCommerce connection or existing order rows (Shopify deliberately excluded until its sync writes webshop_orders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(woocommerce): switch the order sync to webshop_orders, multi-store The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund allocations with parent-prorated VAT fallback) and upserts order rows instead of transactions-inbox rows; already-imported feed rows stay bookable and get cross-marked. Multi-store: several active connections per company, per-store panel cards with the account-mapping editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(webshop-orders): decision log entries and ratchet baseline Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption; hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop-orders): resolve PR #1525 review findings and CI failures Review batch (Superagent, CodeRabbit, Swedish compliance review): - Mutual-exclusion claims: booking guards invoice_id, invoice link-back guards journal_entry_id AND treats zero matched rows as the conflict it is (409 + rollback), closing both TOCTOU races. - Freeze v2 migration (20260812124858): the link columns themselves are protected: invoice links immutable, journal links clearable only while the entry is still a draft (the booking rollback path). - Scraped orgnr no longer auto-written to customers.org_number; rate fallback applies only on single-VAT-bucket orders; refunds get their own WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the invoice-mode hint in the booking dialog. - Ingest compares every synced field (billing corrections no longer drop as unchanged); sync guards absent refunds arrays; /sync aggregates per-store results; panel disables all cards while a request runs; orders page separates load failure from empty; account field explains itself. CI: regenerated skills/accounted-api; pg tests restructured for transaction-abort/rollback semantics + freeze-link coverage; unresolvable- expression ceiling 375 -> 378 with documented reason (partial-update payloads in ingest, shapes covered by unit tests). Declined: CodeRabbit docstring-coverage advisory (house style: comments only where the code cannot say it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
e45218bcc6 |
fix(payments): creditor address on every payment (Validex round 3) (#1509)
Rule 237 fires on the BGNR-to-BGNR path too, so the round-2 reading of rule 020 was wrong: it only relaxes the Ctry element, not the address. Cdtr now always carries PstlAdr (TwnNm from the payee_city snapshot when known, Ctry SE), and the preview warns when the supplier register lacks a city, since rules 237 + 222 together make a town effectively mandatory at Swedbank. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fea5dfd1f9 |
fix(payments): correct pain.001 dialect per Swedbank Validex run (#1507)
* fix(payments): correct pain.001 dialect per Swedbank Validex run Real MIG validation (eken.validex.net) rejected the first generated file on four rules: character set (e-acute in names), missing InitgPty OrgId, BGNR creditors demanding a BGNR debtor, and Strd lacking RfrdDocAmt. Names and messages now transliterate to the MIG set, the org number is required at batch creation (settings first, companies fallback), bankgiro payees debit the company bankgiro in their own PmtInf group when one exists (IBAN otherwise, with Cdtr PstlAdr/Ctry SE always present), and structured OCR remittance repeats the amount as RfrdDocAmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): review quick wins on the MIG pass NFC-normalize before transliteration (decomposed marks from PDF-pasted names fold to the precomposed forms the map knows), a dedicated settings link label for the missing-org state, and coverage for an invalid company bankgiro being dropped from the debtor snapshot. 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> |
||
|
|
f0bedc14af |
feat(payments): betalfil UI (betalfil 3/3) (#1505)
* feat(payments): betalfil UI (betalfil 3/3) Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list, preview dialog with per-line editable amount/date and exclusion reasons, payment-files history page with re-download, cancel and a sequential bulk mark-paid (duplicate guard respected, never forced), I betalfil chip on rows in active batches, clearing/kontonummer fields on the supplier form, and the supplier_payment_files namespace in sv+en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): make the demo AP data betalfil-ready Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo invoice had remaining_amount 0 (no trigger derives it, so the list said 0 kr kvar att betala), and the company had no IBAN/BIC, all of which excluded the seeded data from the betalfil flow. Numbers swapped for Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added, and both bulk-insert rows set remaining_amount explicitly per the PostgREST normalization rule already documented inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |