cd9fb5b717efdd34c6d3a46301e4e730edf4e88e
188 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4ac7b45c8a |
perf(layout): dashboard layout in two waves, nav flags as one RPC, local JWT verification (#1946)
The dashboard layout runs on every hard load, hard refresh, company switch and the 16 router.refresh() sites, and loading.tsx cannot paint until it resolves. It cost ~20 network calls in 4 sequential waves: a third getUser() round trip to Supabase Auth (after the proxy's and the route guard's), the company resolution, then 16 reads including four limit-1 probes whose only job is to decide whether to render the Webshop and Körjournal nav rows, and an entitlements read that itself ran two waves. - lib/auth/claims.ts: claimsPinned/userFromClaims extracted from require-auth.ts (unchanged) so the dashboard request context shares the exact pinning + mapping. getDashboardAuthContext verifies the JWT locally and falls back to getUser() only when claims are missing, unpinned or unverifiable: the proxy already performed the per-request revocation check before the layout runs (same semantics approved for routes on 2026-07-23). - Wave 1 (user-keyed, parallel with the company resolution): team membership, profile, user preferences and the memberships join, which now also supplies the active company's row and role, so the separate companies and company_members reads are gone. - Wave 2 (company-keyed): settings, agent profile, the switcher's settings names, entitlements in ONE wave (getCompanyEntitlements takes the team_id the join already carries and runs the grants read alongside config + subscription), and get_dashboard_nav_flags(). - supabase/migrations/20260826120000_get_dashboard_nav_flags.sql: SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy ordering) and degrades to hidden rows on any other error. - tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs pending WooCommerce, active Shopify, mileage trips, RLS for a member of another company, EXECUTE grants. Unit tests for the wrapper (RPC row, single-object payload, each fallback code, other errors) and for the entitlements teamId option. ~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f338850bd0 |
fix: hide API-archived customers and suppliers from lists and pickers (#1927)
* fix: hide API-archived customers and suppliers from lists and pickers The v1 API soft-archives customers and suppliers (archived_at, plus is_active=false on suppliers) and its own list routes hide those rows behind ?include_archived=true. No other surface filtered archived_at, so an archived counterparty stayed a normal row in the dashboard rosters, the internal /api/customers and /api/suppliers list routes, the MCP list tools and every customer/supplier picker. Apply the same canonical `archived_at IS NULL` filter on every non-v1 list and picker path: - /api/customers GET, /api/suppliers GET (feeds the customers page and the supplier-invoice form) - suppliers dashboard page (reads suppliers via browser Supabase) - InvoiceEditor and NewRecurringScheduleDialog customer pickers; an invoice or schedule being edited keeps its current customer visible (archiving does not refuse on drafts, so a draft can point at one) - deadlines page and CalendarWorkspace customer pickers - InvoicePreviewCard sample customer - gnubok_list_customers and gnubok_list_suppliers: hidden by default, optional include_archived boolean mirroring the v1 flag; rows now carry archived_at so an agent can tell them apart when opted in Detail routes and by-id lookups are untouched: an archived row still opens. The delete-vs-archive semantics are unchanged. The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens of headroom, so even the bare boolean contract crossed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters The two .or('archived_at.is.null,id.eq.<uuid>') filters keep an edited draft's archived customer selectable. The uuid is a runtime value, so the scanner cannot resolve the expression; both columns exist and the filter is covered by the archived-counterparty tests. 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> |
||
|
|
b1a03de34e |
fix(mcp-oauth): api_keys.company_id nullable so companyless signups can mint their key (#1919)
Every fresh Claude.ai authorization died at POST /api/mcp-oauth/token with a silent 500: the multi-tenant refactor's dynamic loop (20260330130000, line ~250) set company_id NOT NULL on api_keys, and the companyless key insert from the popup-signup flow (#1814) violates it. Nothing exercised the real insert before (unit tests mock the client; no pg test inserted an unbound key), so repo, CI and prod all agreed and all were wrong. DROP NOT NULL, log the insert/rotation failures at the token endpoint, and pin the unbound insert + lazy bind on real Postgres. Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a46957167c |
fix: lock down invoice backfill snapshot (#1901)
* fix: lock down invoice backfill snapshot * fix: document snapshot audit follow-up * docs: record snapshot risk treatment * docs: timebox snapshot compliance review |
||
|
|
5fc0be9ed7 |
feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking Booked webshop orders only carried the VAT split; the verifikat showed no product lines, customer or payment method although the sync already stores all of it in webshop_orders.line_items (#1881). - lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf template (order lines, customer, payment method, per-rate VAT summary, SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and archives the PDF on the committed verifikat through uploadDocument (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf. Never throws: the booking is immutable by then. - book route: archive after commitEntry; response gains underlag_archived. FX-retry now also syncs the in-memory row so the underlag shows the resolved SEK facts. - webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration 20260825140000) to the verifikat_without_documents needs-doc list, so a failed attach or a historical booking surfaces on the saknar-underlag worklist. transactions_without_documents is deliberately unchanged. - tests: underlag model/render/archive unit tests, book-route archive and failure-isolation cases, pg test extended (per-source-type probe now covers webshop_order; explicit flagged/silenced pair). Fixes #1881 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): move webshop needs-doc migration after main's 20260825150000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(webshop): add manually_booked fields to the underlag order fixture Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop): skeptic findings on the orderunderlag (#1881) Two refutations from the skeptic pass on PR #1899, both fixed: 1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which Helvetica/WinAnsi PDF fonts drop silently, so refund and discount amounts on the archived underlag rendered as POSITIVE. formatAmount now replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency), is exported, and is pinned by a regression test. 2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed webshop_order, so flagged rows rendered without the "Underlag saknas" chip, waiver toggle, or batch-exempt selection, and the weekly missing-underlag push cron disagreed with the badge. The constant now lives in dependency-free lib/worklist/types.ts (client-safe), is re-exported from categories.ts, and both JournalEntryList.tsx and push-notifications/notification-scheduler.ts consume it instead of their own copies. Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic observation: the dialog's lines are user-editable, so the underlag must state the order's conversion, not claim a booking fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
79013cf092 |
feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) Two deliverables from the community report where a bad SIE test import left no way out short of deleting the company: A) Discoverability: the voucher list shows one attn line linking to /import?history=sie whenever the page contains import-sourced vouchers, and /import?history=sie deep-links straight into the fold-open SIE import history where per-import Angra already lives. B) Reset of an UNLOCKED fiscal year regardless of how the entries arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape hatch as undo_sie_import; no enforcement trigger touched) behind GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed type-the-year-name confirmation dialog on the fiscal years settings list. Refuses on: locked/closed year, company lock date over any part of the year, executed year-end, arsredovisning state, later year depending on this year's UB, VAT-declared evidence (vat_settlement verifikat, SKV lock/submit audit rows, extension workflow keys, fail closed) and AGI-declared months. Entries referenced by RESTRICT/NO ACTION FKs abort the whole reset (all-or-nothing). Documents are detached, never deleted (BFL 7 kap); every delete is audit-logged plus one behandlingshistorik summary row. Fixes #1883 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883) Blocking skeptic findings on PR #1897, one consolidated pass: - New snapshot blocker cross_year_reference: an entry outside the year whose correction_of_id / reverses_id / reversed_by_id points into the year made the delete crash with an uncaught P0001 (immutability trigger refusing the ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and silently severed draft chains. 12 such chains exist in prod today. - New snapshot blocker rot_rut_state: a begaran om utbetalning that reached Skatteverket (submitted/paid/partially_paid/rejected) was silently unlinked via SET NULL, erasing the bokforing behind a filed and possibly decided myndighetsarende. - Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit rows carry no company_id and header rows no amounts, so a reset destroyed konton/belopp with no company-readable trace. The RPC now archives the full content of every verifikat in company-scoped RESET_SNAPSHOT audit rows before deleting (action added to audit_log_action_check, NOT VALID), and behandlingshistorik renders them. - Dimension registry lockstep on reset (mirrors undo_sie_import): flipped imports can never be undone again, so their dimensions/values would have been orphaned forever. - EXCEPTION WHEN raise_exception now returns a typed FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500; gnubok.allow_delete is cleared before leaving the guarded block. - Voucher-list attn line fires only for source_type 'import': opening_balance is also written by year-end closing and the manual IB flows, which mislabelled every year-2+ company as SIE-imported. - /import?history=sie now scrolls the SIE history into view. - Reset dialog copy (sv+en) discloses that linked invoices, payments and bank transactions become unbooked; new blocker strings in both locales. - pg fixture fix: document_attachments seeded without company_id (23502); new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1f9578ca76 |
feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration Orders booked by hand before the store was connected sat under Att bokfora forever: the only exits were the book and create-invoice routes. - Migration: manually_booked_at/_by + optional manually_booked_journal_entry_id on webshop_orders (informational link, no financial freeze; the mark produced no accounting objects). - POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional posted-verifikat reference (validated per company), conditional claim against concurrent booking/invoicing; unmark is a plain revert. - book and create-invoice routes refuse marked rows (409 WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims. - List route: booked/unbooked filters treat a manual mark as a closed exit, so marked rows leave the Att bokfora tab and join Bokforda. - Orders page: row overflow menu with Markera som bokford / Angra markering, MarkOrderBookedDialog with a searchable candidate list of posted entries near the order date, muted status text linking to the referenced verifikat. Fixes #1879 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): close skeptic findings on the manual-booked mark - mark-booked applies the same open-twin gate as book/create-invoice: an OPEN legacy feed transaction blocks the mark (409 WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows unlock it, so no open path to a duplicate remains. - ingest treats manually marked rows as frozen for drift purposes: remote financial deltas set remote_changed_after_freeze (same badge as booked rows) instead of silently refreshing the row under the user's assertion. - re-marking with a journal_entry_id updates the informational link instead of silently dropping it. - dialog: candidate amount computed from the returned lines (the list API does not return total_amount), newest-first ordering, cap hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): resolve PR review findings in one pass - freeze v3 migration: financial fields are frozen at the DB level while a row is manually marked as booked (review finding: the mark's freeze lived only in ingest.ts, so any other write path could silently mutate a marked row); unmark stays the escape hatch. pg test added. - pass the active locale to getErrorMessage in the orders page and MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
31e0cd6e05 |
feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies Third PR of agent-first onboarding (#1814). Once connected, the agent can now set up a company end to end without the web wizard, and partner platforms can provision companies over REST. - create_company_for_user: service-role-only SECURITY DEFINER twin of create_company_with_owner taking the owner explicitly (service clients have no auth.uid()). pg-real test covers creation, role gating, unknown owner and foreign team. - lib/company/create-company.ts: the wizard's creation sequence (org number, TIC snapshot, BAS chart, settings, first fiscal period, tax deadlines, rollback) extracted into createCompanyCore; the Server Action delegates to it, behaviour unchanged. - lib/company/onboarding-input.ts: one Zod schema + planner for the agent/API paths; a VAT-registered company without moms_period is refused (a missing period silently yields zero VAT deadlines). - MCP: gnubok_create_company (two-phase: preview, then confirm=true; companies:write, company-independent), gnubok_connect_bank and gnubok_connect_skatteverket (status + the browser link, gated on bank_sync / skatteverket, search-only in the catalog), the "onboarding" skill, and initialize instructions pointing at it. - Consent page pre-ticks companies:write for an account with no company yet, so the setup does not dead-end on insufficient scope after signup. - POST /api/v1/companies (companies:write, dry-run aware) on the same core; scope map, registry, spec snapshot and the generated API skill updated. - tools/list payload ceiling raised 59.95K -> 60.4K for the one new default-catalog tool (documented in the guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec Review findings on #1864 (Swedish compliance review): - f_skatt is required, never defaulted to approved (SE-R-005 risk). - org_number is required when vat_registered: the invoice momsregistreringsnummer derives from it (ML 17 kap 24 §). - An enskild firma's first fiscal year must end on 31 December and its start month is forced to 1 even with first_fiscal_year set, mirroring the wizard's own rule text (BFL 3 kap. 1 §). - POST /api/v1/companies no longer claims Idempotency-Key support (the wrapper only honours it on company-scoped routes). - pg-real: createCompanyCore's chart seed runs under the real service_role, which the unit tests could not prove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * test(pg): starter chart has 41 accounts, assert non-empty The service_role chart-seed proof passed the part that mattered (no 42501 from seed_chart_of_accounts) and failed on a wrong row-count guess: the seeded chart is a curated starter set, not the full BAS list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(migrations): move create_company_for_user to 20260825120000 main gained 20260824170000_bulk_book_transactions_service_actor.sql with the same version while this branch was open; two files on one version abort every Supabase branch apply and the prod auto-apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * chore(api): refresh spec snapshot and generated skill after rebasing onto main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(mcp): flat create_company result, refuse localhost connect links, test hygiene CodeRabbit on #1864: the confirmed-create result was wrapped in the { data, next } envelope while its outputSchema promised top-level fields; it now returns the fields with next as a sibling. The two connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is unset instead of handing a remote user a localhost URL. Tests clear mocks and the event bus in beforeEach. Not changed: the rollback already survives user_preferences.active_company_id (that FK is ON DELETE SET NULL since 20260331010000), and v1 error details stay in the surface's English developer convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dc92fb5c0c |
fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op (#1852)
* fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op Feedback seq 261545 (deepCFO): approving a bulk_book_transactions op over MCP returned BULK_BOOK_UNAUTHORIZED, yet the op vanished from /pending with nothing booked; the user believed it had been approved. Two defects: 1. The bulk_book_transactions RPC gates on auth.uid(), which is NULL on the cookieless service client every MCP approval runs on, so EVERY API-key approval of a samlingsverifikat was refused. New migration 20260824170000 adds p_user_id, honored only for service_role callers (same gate as match_batch_allocate 20260817150000 and undo_sie_import); the executor passes the approving user, who is now also the actor stamped on the verifikat. pg-real test covers member/spoof/no-JWT/ grants like the precedent. 2. The dispatcher consumed the op on ANY executor error other than 404/ 409. An authorization refusal happens before any side-effect and says nothing about the op, so 401/403 now release the claim back to 'pending'. The executor maps RPC codes through the structured-error registry so 403/404/409 are distinguishable from 400. Every CommitResult carries operation_status (pending | committed | rejected | failed_partial), exposed on gnubok_approve_pending_operation, so agents stop inferring consumption from status 'failed'. Catalog token ceiling 59.95K -> 60K per the documented ratchet protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ * fix(pending-ops): revoke anon explicitly on the service-actor bulk_book signature Default privileges grant EXECUTE on new functions to anon; the pg-real grants test (mirroring match_batch_allocate) caught it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c62321988b |
feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> 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> |
||
|
|
d88df74b85 |
feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad / Ränteintäkt / Öresavrundning och koppla' books the remainder on 6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (Skatteverket posts ränta and avgifter as rows of their own), capped at 5 000 kr, direction-checked against the kind; links are made first and undone if the booking is refused. Dashboard + v1 doors (transactions:write, Idempotency-Key, dry run), API skill regenerated. The bridge now treats transaction_voucher_links as links on both sides: migration 20260824190000 re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching to count junction-linked verifikat as matched (pg-real test), and the TS engine + items do the same for the transactions. This also stops bulk-booked samlingsverifikat from polluting the open buckets. 'Koppla bort' drops the junction rows too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
174f315d9c |
fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach (#1860)
* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded underlag could only be replaced, never removed. - POST /api/supplier-invoices/[id]/book now calls anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967) flow was the last booking surface that never anchored the invoice's retained source document, so every missing-underlag surface kept flagging the registration verifikat until payment. - Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows created since (idempotent, open unlocked periods only). - New detach_underlag_duplicate RPC (migration 20260824151000): the one sanctioned path to detach a redundant duplicate underlag from a posted verifikat. Guarded: writer role, open unlocked period, company lock date, at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned docs (transactions/supplier_invoices.document_id) stay replace-only. Audit-logged first, transaction-local gnubok.allow_delete carve-out. The file is never deleted: it returns to the unlinked pool. - POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en). - Tests: book-route anchor assertions, detach route unit tests, pg-real suite for the RPC incl. the direct-UPDATE-stays-blocked invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): harden detach_underlag_duplicate per skeptic findings - Require sha256 identity: detach only when a remaining anchored sibling carries the same immutable sha256_hash, so only byte-identical duplicates ever leave a verifikat (two different handlingar both stay behind the WORM guards). UI gates the button on the same condition. - Enforce the documented posted-status guard (reversed/cancelled verifikat refuse detach). - Set company_id on the RPC's audit_log row: the SELECT policy filters on company_id, so the provenance row was invisible to every reader (same defect 20260528120600 fixed for delete_last_voucher). - Swedish 403 message on the tenant guard (CodeRabbit). - pg tests: closed-period case now seeds open and closes via UPDATE (the period-lock trigger blocks seeding into a closed period), duplicate pairs share a hash, added non-duplicate and reversed-entry refusals, audit assertion pins the RPC's own row (description + company_id + actor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f40795896f |
feat(reconciliation): sign-off, period picker, Hem row and the three doors for it (#1835)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
62135c0c02 |
fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)
A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning dialog + MCP path) had no öre handling, so a sub-krona overshoot was rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice in partially_paid forever. Invoices already stuck that way had no exit: the mark-paid dialog proposed clearing the full total (rejected with MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid outright. Fix, both halves: 1. New migration 20260824120000 replaces match_batch_allocate with the same öresavrundning band every single-payment path already uses (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff clears the FULL remaining off 1510/2440, books the residual to 3740 with correct polarity per side, records the full remaining as paid and flips the status to paid. >= 1 kr diffs keep today's behaviour. 2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual invoice gets a proposal clearing the actual remaining, and a sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so one click closes a stuck invoice. The mark-paid route and the invoice-detail button now accept partially_paid (the settle layer's CAS guard always did). Forward-only: already-stuck invoices are not auto-repaired; they are closed via the new dialog proposal. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6e5694fd03 |
feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it (#1809)
* feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it Customer report: neither the MCP transaction listings nor v1 REST said which bank account a transaction belongs to, so per-account reconciliation could not be driven from outside and a difference on one account was hunted on another. - gnubok_list_uncategorized_transactions: cash_account_id + cash_account_ledger (BAS account of the bank account, one lookup per page) on every row, and an optional cash_account_id filter applied to both count and page. - transactions_without_documents RPC (new migration, same signature): rows carry cash_account_id + cash_account_ledger via LEFT JOIN cash_accounts; gnubok_list_transactions_without_documents declares them. - v1 transactions list/detail: cash_account_id column; list accepts ?cash_account_id=<uuid> (400 on non-UUID). - tools/list budget bumped 59.85K -> 59.9K with the usual log entry; no property descriptions added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): import insertCashAccount in the pg test, regenerate banking.md, validate cash_account_id Skeptic/CI findings: the new pg-real test referenced insertCashAccount without importing it; the accounted-api agent skill (banking.md) was stale after cash_account_id joined the v1 projections (apiskill:check). Also reject a ledger number passed as cash_account_id on the MCP tool with a clear message instead of a raw uuid cast error, since the ledger now sits next to the id in every row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3ac80edc96 |
feat(peppol): gate Peppol per company: request access, operator enables with a sending cap (#1794)
* feat(peppol): gate Peppol per company: request access, operator enables with a sending cap Peppol is no longer available to every company by default. Each transmission is billed per document by the access point and each receiving identifier consumes a contracted tenant slot, so the product now works like this: - peppol_access (new table, RLS read-only for members, service-role writes): status requested | enabled | disabled, max_sends (null = no cap), receive_enabled as a separate grant, who asked and who enabled. - POST /api/settings/peppol/access: the company asks from Settings > Fakturering; the row is written and the operators are e-mailed (best effort, the row is the source of truth). - scripts/peppol/access.ts list | enable <company|orgnr> [--max-sends N] [--receive] | disable | show: the operator side. - POST /api/invoices/[id]/peppol/send refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice; the invoice page's send item says so instead of pretending. Registration for receiving refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_RECEIVING_NOT_ENABLED. - Settings UI: access status row with "Begär åtkomst", sends used of cap, receiving switch only once receiving is granted. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): pass route params to the settings handlers; baseline-align the access row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): revoke default table privileges from authenticated on the access and receiving tables Supabase grants ALL on new tables to authenticated by default; the earlier REVOKE covered PUBLIC and anon only, so a member's UPDATE on peppol_access was an RLS-filtered no-op instead of a permission error (pg-real caught it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f93152c397 |
feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)
* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery Second Peppol slice (#546). Qvalia confirmed that sending needs no per-company account, so receiving keeps the consolidated partner account: each company publishes its 0007:orgnr on our account and inbound documents are routed by the AccountingCustomerParty endpoint. - PeppolTransport grows optional receiving methods (registerRecipient, unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices / readcreditnotes, exact XML fetch). - lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON (xml2js-style prefixed keys, verified against Qvalia's real inbound test invoice, kept as a fixture) into a neutral document: parties, payment means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines, embedded attachments, credit notes. - Migration 20260821170000: peppol_registrations (one live row per company and participant), peppol_inbound_documents (exact XML immutable and undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability and routing. - POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in Settings > Fakturering; personnummer-based companies are refused until 0088 GLN exists; sandbox refused. - GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver. lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document (upload_source e_invoice, extractionOwner none), an embedded PDF when present, and creates the inbox row with the extraction filled from the UBL (confidence 1, no model pass), matching the supplier by org number. The existing inbox review/convert flow takes over. - document-service accepts application/xml for the archive; inbox list shows a Peppol icon. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES; the pg fixture for a deregistered row now carries deregistered_at as the status-shape constraint requires; the archive insert is an inline literal and the one generic processing-state updater is accounted for in the ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
704bf93e08 |
feat(categorize): confidence calibration engine + measurement loop (cascade step 4) (#1784)
Turns the selector's raw confidence into a score that means what it says. - lib/agent/categorize/calibration.ts: the engine. Isotonic regression (pool-adjacent-violators, distribution-free + monotonic) over (confidence, was_correct) samples → a calibrator; plus reliabilityByBucket, ECE, and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap. 12 engine tests (overconfidence pulled down, underconfidence lifted, monotonicity, ECE, band gating). - Measurement loop: migration categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]) + POST /api/agent/categorize/ outcome logging one sample (proposed vs actually booked) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped). AiCategorizeProposal surfaces the proposal metadata via onProposal. - scripts/fit-categorize-calibration.ts (read-only): prints the reliability diagram + ECE + fitted calibrator once data has accumulated. Fitting needs a few hundred real outcomes, so nothing calibrates today — the loop starts collecting, and "säker" stays uncalibrated (no auto-book) until the data proves it. 131 unit tests green; RLS covered by a pg-real test. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
47c039453c |
feat(import): undo a bank file import including ignored transactions (#1764)
* feat(import): undo a bank file import including ignored transactions (#1672) A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips the bad rows, the single-row DELETE refuses imported rows by design (TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions also never recorded which import batch inserted them, so a strictly scoped undo was impossible. - transactions.bank_file_import_id: batch link stamped at ingest by both bank-file import paths (dashboard execute route, v1 REST route). PSD2/ manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution could delete rows belonging to a different import. - undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's unbooked rows, ignored INCLUDED. Booked rows (journal link, payment rows, voucher links) and rows with append-only payment_match_log history are skipped and reported, mirroring the single-row route's guards. Marks the import 'undone' (re-import reuses the row via the company_id+file_hash upsert), writes one audit_log summary row, and hardens the actor gate like undo_sie_import: p_user_id honored only for service_role callers, 42501 otherwise, no anon EXECUTE. - DELETE /api/import/bank-file/[id]/undo returns the deletion report; RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403). Closes #1672 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): return 404 when the bank-file undo target does not exist An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED, hiding the not-found semantics the SIE import routes already expose ('Import not found', 404). Flag the case in undoBankFileImport (notFound) and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404); status-refusals and RPC failures keep the 400 envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * feat(import): show bank file import history with undo on the import tab The undo shipped for issue #1672 was API-only: no surface listed a company's bank_file_imports, so neither users nor founders could reach DELETE /api/import/bank-file/[id]/undo, and the deletion report existed only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574): - GET /api/import/bank-file: list the company's imports newest-first, same { data, count, limit, offset } shape as GET /api/import/sie. - BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on the Importera tab with filename, date, format, imported count and status per import, plus an undo action on completed rows behind a DestructiveConfirmDialog. The undo stays owner/admin-only via the undo_bank_file_import RPC's actor gate, like the SIE one. - After undo the toast shows the full report: transactions removed, booked rows skipped, rows with match history skipped, so nothing disappears silently from the ledger's surroundings. - i18n strings in messages/sv.json and messages/en.json following the sie_history_* key style; list-route test mirroring the SIE list test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution Review findings on #1764 (CodeRabbit): - GET /api/import/bank-file rejects non-integer/negative/oversized limit and offset and unknown status with a mapped 400 (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and invalid-input tests added. - undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404) from other lookup failures, which now return an error instead of masquerading as a permanent 404. - The v1 import route no longer discards the bank_file_imports upsert error: kept non-fatal by design (an unattributed batch imports fine and never appears in undo history), but the failure is now logged loudly. - Route test beforeEach clears the event bus (repo convention). Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9fc05c383f |
feat(notices): one aggregated notice line instead of stacked degraded-state banners (#1733)
* feat(notices): lib/notices aggregator + single notice line on Hem
Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:
- lib/notices/{types,predicates,categories,aggregate}: five documented
categories with a fixed priority order; every predicate soft-fails to
null; pure decision helpers live in predicates.ts so 'use client' pages
can import them without pulling server-only modules. Broken supersedes
expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
notice_dismissals table (per company+user+notice_id, RLS user-scoped).
Notice ids embed a state discriminator, so a dismissal hides exactly
the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
boxed BackupHealthBanner card sat (banner deleted; its multi-provider
sentence logic moved into the backup_failing predicate), with a quiet
"+N till" inline expander. otherAccountHint joins the same list as the
lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
the reconnect decision from the shared skvStatusNeedsReconnect /
skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
räkenskapsinformation) to satisfy the full-archive contract.
SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy
Review fixes on the notice aggregator:
- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
(version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
SAME incident persists and so resurrected a dismissed notice daily. The
id is now stable per (provider, reason), and the opposite direction is
kept correct by stale-dismissal reaping in getCompanyNotices: when a
category is currently healthy, the caller's stored dismissals for that
category (matched on the 'category:' id prefix) are best-effort deleted,
so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
the other ids: bank ids embed connection id + status/expiry and skv
embeds the incident's first-error/expiry timestamp (markNeedsReconsent
only fires post-connect), all stable per incident; they get the same
reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
the English message: a bank_broken_one_unnamed message variant (sv + en)
is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
now collapses to count + first 8 hex of a sha256 over the sorted parts
(node:crypto, server-only) instead of concatenating uuids; single
connection ids stay human-readable. Dismiss schema cap tightened to 200
with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
healthy state reaps, new failure after reap resurfaces, hint never
reaped, failed reap swallowed, 30-connection id under 200 chars and
stable across orderings, unnamed-bank variant, sorted backup id stable
across cron re-stamps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(notices): pg-real coverage for the notice_dismissals policies
The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.
Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.
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>
|
||
|
|
926ca75fd3 |
fix(rattelse): let a bank-side strike land on the linked bank amount (#1701)
The inline rättelse RPC refused any net change on a 19xx account when the verifikat is linked to a bank transaction, including the one change that makes a mis-booked bank side match the feed (1930 D / 1930 K against a deposit, credit should have been 2970). Anchor the guard to the signed linked bank amount instead of to the pre-state: a non-zero change on the bank account is allowed iff the post-state net equals the linked amount (once per transaction, split links by allocated_amount). Reskontra sides stay strictly net-preserving. The refusal message now shows both amounts. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3a1b842e4a |
feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates |
||
|
|
e70718eea3 |
fix(matching): make match_batch_allocate commit from the MCP approval path (#1645)
The pending-operations commit path runs on the cookieless service client, where auth.uid() is NULL, so every MCP-approved batch allocation returned BATCH_UNAUTHORIZED since 20260601122000 dropped the p_user_id argument. The web /pending path only worked because it carries a cookie session. Re-add p_user_id gated exactly like undo_sie_import (20260727121000): honored only when auth.role() = 'service_role', every other caller is pinned to its own auth.uid(), so an authenticated PostgREST caller cannot impersonate. The commit handler now passes the approving user through, which also attributes the journal entry and payment rows to the human who approved instead of failing outright. The 3-arg signature is dropped (the new 4th arg has a DEFAULT, so the HTTP twin's 3-arg call still resolves); grants re-asserted: no PUBLIC/anon, authenticated + service_role only. Reported via gnubok_feedback 2026-07-24 (codex/hermes) and 2026-08-06. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a447b29210 |
fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice (#1655)
* fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice remaining_amount is NOT NULL DEFAULT 0 and every payment surface (payment dialog, bank match, Stripe sync, agent mark-paid) reads it as the customer's open balance. Four writers omitted it, so their invoices looked settled: the dialog rejected every payment as an overpayment and the bank match saw nothing to clear. Prod carried 337 such open invoices on 2026-08-17 (backfilled the same day, snapshot in _backfill_remaining_20260817). - Migration 20260817191708: BEFORE INSERT trigger invoices_derive_remaining_amount. When remaining_amount is NULL/0 on a real invoice (document_type invoice, not a credit note) with total > 0 and a status that still owes money, it becomes total - paid_amount - deduction_total (>= 0). The ROT/RUT share is a 1513 receivable on Skatteverket, never the customer's, exactly as buildInvoiceWriteData computes it. INSERT only: settlement code owns updates and legitimately writes 0 when paid in full. - pg-real test: derivation, explicit value respected, paid/prior/deduction arithmetic, drafts + overdue, paid/cancelled keep 0, credit notes and proformas untouched, never negative. - Writers fixed as well: proforma -> invoice conversion (dashboard route and MCP commitConvertInvoice), MCP commitCreateInvoice, sandbox seed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): every row in the seed invoice batch carries remaining_amount + paid_amount PostgREST normalises a bulk insert to the union of keys, so a row that omits a column the others set arrives as NULL, not as the default. Keep the draft row on the same contract as the rest of the batch (CodeRabbit). 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> |
||
|
|
4921d1da5e |
feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)
* feat(import): import skattekontoutdrag files into the skattekonto pipeline Users can now upload the kontohändelse export from Skatteverket's skattekonto e-service (current CSV layout, verified against a real 2026-08 export, plus legacy .skv files) instead of needing the paid API connection. Parsed rows land in skattekonto_transactions as booked file_import rows and inherit the existing 1630 rules engine, bulk booking, match-to-verifikat and both UIs unchanged. - Core parser lib/import/skattekonto-file/ with strict detection (orgnr header + saldo markers, or two distinct SKV vocabulary terms plus row shape), sum-integrity check (opening + rows must equal closing) and a wrong-company guard against company_settings. - computeDedupKey moves to core (lib/skatteverket/skattekonto-dedup); the extension re-imports it. File rows hash-key; content-signature partitioning skips rows already booked (either key form) and promotes matching upcoming rows in place. - syncSkattekonto gains a takeover step: an id-keyed API row adopts a matching hash-keyed imported row in place, so journal links survive connecting the API after a file import. Upcoming rows can no longer clobber a booked row on hash collision. - New skattekonto_file_imports table (company-scoped file-hash dedup) plus source/file_import_id provenance columns on skattekonto_transactions. - /import gains a Skattekontoutdrag wizard (upload/preview/result, deep link ?mode=skattekonto); the bank-file flow detects skattekonto files and redirects instead of importing them as bank rows. - /skattekonto renders imported rows for unconnected companies (attn line + import CTA) instead of discarding them behind the StartCard. - Free for everyone: the local-data booking/match routes were already ungated; only API sync/saldo stay capability-gated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skattekonto): align the EF F-skatt rule with the 2012 -> 2013 decision 20260810120000 established that 2012 is not standard BAS and moved the booking templates to 2013 (owner taxes in an enskild firma are an eget uttag), but the skattekonto_rules seed still booked EF preliminarskatt against 2012. The file importer makes this rule fire for every EF F-skatt row, so bring it onto 2013 too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): apply review findings on the skattekonto file import - Fix the takeover candidate comparator: the single-argument sort was an inconsistent relation and could adopt a stale upcoming row ahead of the booked file row in a 3+ candidate queue (regression test added), and page the candidate scan with fetchAllRows so a multi-year window is not silently capped at 1000 rows. - Fail parsing when a statement HAS saldo markers but not both readable balances: a file cut off before "Utgående saldo" previously skipped the sum check entirely. sum_valid stays null only for marker-less legacy files. - Count a promotion only when the UPDATE matched a row, so a concurrent sync cannot inflate promoted_count; log a failed finalize of the import record instead of discarding the error. - Migration (unshipped, edited in place): user_id is nullable with ON DELETE SET NULL so import records and their file-hash dedup survive user deletion, and the INSERT policy binds user_id to auth.uid() so a member cannot attribute an import to a colleague. pg tests cover both. - Make the upload drop zone keyboard-reachable (role, tabIndex, Enter/ Space) and give the six count-bearing strings ICU plural forms in both locales. Skipped with reasons on the PR: binding execute rows to file bytes and re-checking orgnr in execute (same client-trust model as the shipped bank-file execute; Zod + RLS scope writes to the caller's own company), a 404 test (the route has no not-found path), event-bus clearing in the route test (the route touches no events), and FK NOT VALID (new column referencing a brand-new empty table). 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> |
||
|
|
edfdbe2d2a |
fix(auth): move the BankID flow into a signed, user-gated, single-use cookie (#1625)
* fix(auth): move the BankID flow into a signed, single-use, confirm-on-resume cookie
A user's BankID signup identified successfully four times and created no
account. His screenshots show four tabs, one on the finished "Verifierad med
BankID, ange e-post" step, and the tab he was looking at showing the idle
button. Prod agreed: no bankid_identities row, no auth.users row.
On iOS outside plain Safari the BankID return URL is handed to the OS, which
opens a NEW tab. The session lived in per-tab sessionStorage, so that tab
started empty and rendered the start button while the completed flow sat
stranded. Login hid it (self-finishing, cookie-backed session); signup waits
for a human to type an e-mail into the stranded tab, so it dies there.
The session id is no longer handed to the browser. It lives in a signed
__Host- HttpOnly cookie set at /start; /poll, /complete, /link and /cancel
read it. Cookies are shared by every tab of the origin, which is what the
handoff needed. The id had to leave the client because it is an
unauthenticated bearer credential: /poll was skipAuth and returned
user.personalNumber, and /complete with mode 'login' returns a tokenHash that
verifyOtp turns into a session, MFA skipped for bankid_linked accounts.
A completed identification must never be consumed by whoever merely opens the
page. A shared cookie plus a shared machine means the tab that finds a
completed flow cannot prove the person at it is the one who made it, and no
client-side token can prove otherwise: nothing survives an iOS same-tab reload
yet dies on reopen-closed-tab / session restore / tab duplication. So a resume
is never automatic. The mount probe routes any found live flow to a confirm
card ("Fortsätt bara om det var du") that reveals no name, and only that click
polls and consumes. Auto-consume happens only inside the live component
instance that called startSession (desktop QR; the pre-navigation mobile
launch), which by construction is the originator. Cost: one tap after
returning from the BankID app on iOS, exactly where the reported bug lives;
desktop and Android never hit the resume path.
The rest is defence the four review rounds proved load-bearing:
- __Host- with Path=/ and unconditional Secure, so a script cannot plant the
same name at a longer path; readBankIdFlow fails closed on duplicates and on
a malformed percent-escape.
- Single-use is a unique index (bankid_consumed_sessions), claimed before
generateLink, not a Set-Cookie. Fail-closed on any non-23505 error, so the
migration MUST be applied before the code.
- A link flow requires auth at /start and pins userId; /link rejects a flow
owned by anyone else, before any TIC call. mode is pinned and /poll rejects a
body mode that does not match, so a login session cannot finish through the
signup panel. /poll withholds the holder name from a probe. The 900s
verified-step window is capped by MAX_TOTAL_LIFE from a signed startedAt.
/poll never clears the cookie (an untargeted Set-Cookie would delete a newer
flow); only /cancel and terminal /complete + /link exits clear. Avbryt holds
a 'cancelling' state until /cancel resolves so a new /start cannot race the
clear. Session id is logged only as an 8-char prefix.
The launch is untouched: iOS keeps its return URL, Android keeps redirect=null
(#194 closed that path deliberately).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(auth): bind BankID actions to the resumed flow
* docs: record BankID staging migration drift
* fix(auth): address BankID PR review
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2deea05d42 |
feat(import): attach underlag to SIE-migrated verifikat by filename (#1627)
* refactor(documents): lift the SIE voucher-ref resolver into core
The provider migration sweep resolved a source voucher reference to the
verifikat it became with an in-memory (period, series, number) index built
inside extensions/general/arcim-migration. The underlag filename import needs
the identical resolution, and core must never import from @/extensions, so the
index, its ambiguity handling and the two paged reads move to
lib/documents/voucher-ref-resolver.ts.
Behaviour-preserving for the extension: same index construction, same "drop
both when one key repeats inside a fiscal year" rule, same dateTo-window
resolution. The arcim tests pass unchanged.
Two deliberate additions on top of the lift:
- series comparison is now case-insensitive on both sides. SIE writes series
uppercase in practice but the spec does not require it, and a filename is
whatever the exporting tool produced.
- byNumber and fetchVouchersForNumbers serve the filename flow, which
resolves a handful of refs per request and must not pull every migrated
entry into memory to do it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(import): attach underlag to SIE-migrated verifikat by filename
A SIE file carries the ledger but not the underlag, so a migrating customer
brings the receipts over separately and today has to open every verifikat and
attach them by hand. Systems that export both name each receipt after its
verifikat (A31_<internal-id>.pdf), and the SIE import already preserves that
identity on every entry (source_voucher_series / source_voucher_number), so
the pairing is a lookup, not an interpretation: no AI, no amount matching, no
date windows.
Separate optional import mode (/import?mode=underlag), NOT a step inside the
SIE wizard: the receipts normally arrive later and from a different export, so
a migration must never be blocked on having them ready.
lib/documents/filename-voucher-ref.ts reads the ref out of a filename
lib/documents/underlag-import.ts builds the plan (reads only)
POST /api/import/documents/preview filenames in, match plan out
POST /api/import/documents/attach one file, archived and linked
components/import/UnderlagImportWizard review, adjust, run
Guards, because a document linked to a posted verifikat is
räkenskapsinformation and can never be re-pointed (BFL 7 kap):
- Matching keys on the SOURCE voucher number, never our own. The importer
renumbers per target series, so a file named after our number would land
on the wrong verifikat exactly when the import skipped a voucher.
- Nothing is uploaded until the whole plan has been shown: the preview
sends filenames only, the bytes stay in the browser.
- A ref that hits several migrated years is surfaced as a choice, never
resolved by guessing. So is a filename with a number but no series, which
is resolved but never pre-selected.
- A date-named file (20240131.pdf) is refused outright rather than read as
voucher 20240131.
- A target in a closed or locked period is shown but not selectable:
enforce_period_lock_documents would refuse the write anyway.
- The attach route re-resolves the filename server-side and 409s when it
does not name the target the client sent, so a stale plan cannot scatter
underlag permanently. An explicit manual assignment opts out of that check
and is flagged as such; company ownership of the entry is always verified.
- Idempotent per (verifikat, content): a re-run converges on the same
document row instead of archiving duplicates.
tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract
the plan surface promises, including that the lock guards the LINK and still
lets an unlinked document be archived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): scope underlag matching to a declared fiscal year
Adversarial review of #1627 refuted the resolver: it looked a ref up
company-wide and treated "exactly one candidate exists" as proof of identity.
Source systems restart voucher numbering every year and a filename carries no
year, so with a partial migration, or with that year's A31 among the vouchers
the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt
was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and
invisible afterwards. Cardinality is not identity.
Every batch now declares its fiscal year and candidates outside it are dropped
before the index is built, so no downstream branch can see, count or propose
one. The attach route takes the year for its re-resolution from the TARGET
entry, never from the client, so the check cannot be widened by naming a
different year. Scoping cannot make the year inferable; it makes it asserted,
and the confirm dialog reads it back because it is the one input the files
cannot corroborate.
Four further defects from the same review:
- npm test went red: hoisting the column list into a VOUCHER_SELECT constant
hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and
dropped all eight journal_entries columns out of the guard on the one path
that writes irreversible links. Both selects are inline again, and split:
the provider sweep no longer fetches three display columns it never reads.
- The date guard only caught zero-padded hyphenated dates, so
`2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all
parsed as voucher 2024 or 24. Widened to unpadded components, two-digit
years and space/slash separators; a bare year-shaped number is refused.
- `Verifikation 31.pdf` parsed as series ION: the alternation matched
`ifikat` and left `ion` for the series group. Reordering alone was not
enough (the engine backtracks into it), so the prefix now requires the
word to end.
- The manual-reference box was an unguarded write path: typing a date got
path-split down to a voucher number, marked the row selected, and posted
with override, which skips both server checks, while the row still showed
"Kan inte tolkas". Directory splitting is gone from the parser, the row
status is updated on resolve, and picking a server-proposed candidate no
longer counts as an override, which had disabled the filename check on
exactly the ambiguous rows it exists to protect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): enforce the declared fiscal year on the server
The second adversarial pass refuted the previous fix. The attach route took
the year for its re-resolution from the TARGET entry, which is tautological:
an entry is by construction inside its own fiscal_period_id, so the filter
could never drop it and the year axis was unfalsifiable. Server-side year
enforcement was zero; the declared year existed only as React state and was
never sent. The regression test that "proved" otherwise passed only because
the mock let one journal_entries row report two different fiscal_period_id
values to two different reads, a state Postgres cannot produce. A test that
could not fail.
The attach request now carries the year the user actually reviewed, echoed
back from the plan, and the route asserts it equals the target's own period
BEFORE any other check and including overrides: an override is a statement
about which verifikat, never about which year. Its test asserts that directly
instead of a mock artifact.
Also from the same pass, a UI race that made the confirm dialog lie: FyPicker
stayed interactive while a preview of up to 2000 filenames was in flight, so
the summary and the confirm text could read back a year the plan was not built
from, and a manually resolved row could join the batch from another year
entirely. The wizard snapshots the plan's year, every downstream read uses the
snapshot, manual re-resolution goes through the server's own echoed
plan.fiscal_period_id, and the picker is frozen while a preview runs.
Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids,
no ReDoS found: 2000 hostile inputs in 26ms):
- Day-first and US dates parsed as voucher numbers: `31.01.2024` became
voucher 31, a number that always exists in the year. The guard now covers
both orders.
- `ver 31.pdf` parsed as series VER and came back auto-selectable, while
every spelled-out `Verifikat 31.pdf` correctly yielded a series-less
reference needing confirmation. Same filename, two trust levels, decided
by an abbreviation. `ver` is no longer a series.
Known residual, stated rather than papered over: a scanner's `A4.pdf` or a
`K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10
when that year has them. No parser can separate those from a genuine
reference; they appear in the review table with the target's date and
description.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): make the user actually declare the fiscal year
The third adversarial pass found that the central guarantee of the previous
two commits was fiction. FyPicker auto-selects the newest fiscal period when
nothing is stored, and the wizard passes a page-specific storage key, so that
branch fired on every first use. A user migrating 2023 receipts who never
opened the picker resolved them against the newest year; A31 exists in
essentially every year, so those rows came back `matched`, pre-selected, with
only the confirm dialog between them and permanent links. Every commit message
and code comment claiming "the year the user named" described behaviour the UI
did not have.
FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other
caller changes, and the wizard uses it. The picker starts empty and the batch
cannot proceed until someone picks. A previously stored explicit choice for
this surface is still restored, which is what makes a multi-batch migration
bearable.
Also: a company with zero fiscal periods hit a disabled picker and a disabled
button with no explanation. There is now a line saying why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): close the restore-branch hole and demote collision-prone refs
Round four of adversarial review, two findings, both fixed.
1. `requireExplicitChoice` gated only the newest-period fallback, not the
localStorage restore branch above it, so the "user declares the year"
guarantee held only for a user's first-ever batch. From the second on, the
year was silently pre-filled from an earlier unrelated batch, and in a
multi-year migration last-used is the worst possible default: the user is
by definition moving to a different year each round. The prop now gates
FyPicker's ENTIRE auto-selection block with one outer condition (restore,
the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a
per-branch gate already missed one branch once. It also suppresses the
localStorage write, which fired BEFORE onChange and so recorded picks the
wizard had rejected mid-preview. The wizard drops its storage prefix
entirely: within one sitting reset() carries the year in state, and
nothing survives the session.
2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring
a click for `31.pdf`, which carries MORE voucher evidence in a
single-series company. Two independent review passes flagged the same
inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/
T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/
SCN are cameras; real SIE series are 1-2 chars) still parse and resolve
but are never auto-selected. Demoted, not refused: verifikat A4 genuinely
exists in every migrated ledger, and its real receipt costs one click.
Residual documented: an existing short series plus a small number in an
ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by
filename alone.
Also: the attach route's multipart doc now names the required
fiscal_period_id field, and the stale reset() comment describes the actual
persistence model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): honor override only for unresolvable filenames + review round
Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish
accounting review, compliance swarm).
The one substantive finding (CodeRabbit, major): `override: true` skipped the
filename consistency check entirely, so a crafted client could attach a
cleanly-named file to any same-year verifikat. The resolver now runs on every
request; an override is honored only when the filename is unresolvable in the
declared year (no parse, or no candidate) or already resolves to the requested
target. The shipped UI only overrides unresolvable rows, so nothing
user-facing changes. planAcceptsTarget is renamed planPermitsAttach and
carries the semantics in one place, with tests for both directions.
The Swedish review finding (BFNAR 2013:2 systemdokumentation): the
planPermitsAttach JSDoc still described the superseded derive-the-year-from-
the-target design. It now states the actual control: the route asserts the
caller-declared year equals the target's own period before this function runs.
CodeRabbit minors and nitpicks:
- underlag_confirm_body / underlag_run / underlag_locked_warning use ICU
plural forms in both locales; "1 filer arkiveras" was wrong Swedish.
- The attach and preview route tests mock @/lib/supabase/server per the
repo test guideline.
- fetchVouchersForNumbers narrows to the declared fiscal year at the DB;
the in-memory filter in buildUnderlagPlan remains the enforced truth.
- buildVoucherIndex appends into existing arrays instead of copying per
row: the provider sweep indexes every migrated entry in the company and
per-row copies made that O(n^2).
- The pg test reuses its insertDocument helper instead of a duplicated
INSERT; runAttach clears isLoading in a finally.
Declined, with reasons in DECISIONS.md: message-regex classification of
validateDocumentFile failures (established sibling pattern; validator
contract change is out of scope).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): attach only to posted or reversed verifikat
Second review cycle on PR #1627: the Swedish accounting review's re-run found
that nothing in the attach route verified the target entry's status. The SIE
import RPC posts every entry inside its own transaction, so a draft carrying a
source ref should be unobservable, but the link this route writes is
irreversible räkenskapsinformation, and an invariant enforced in another file
is not one this surface may lean on. Underlag references a verifikation
(BFL 5 kap 6-7 §), so the target must BE one.
Enforced twice: the route rejects non-posted targets with
UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter
to posted/reversed so a draft can never even become a candidate. Reversed
stays attachable: a storno'd original remains räkenskapsinformation and its
underlag belongs on it.
Also recorded as confirmed-intentional (review note, no code change): with
override and an unresolvable filename the endpoint links to any same-company,
same-declared-year, posted verifikat, migrated or not, which mirrors the
existing /api/documents/[id]/link capability. The period-lock error-string
regex note restates a disposition already recorded in DECISIONS.md.
The arcim test's Supabase double learns .in(), which the shared resolver read
now uses for the status filter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
18c20e68e6 |
fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals Skatteverket connections become per (user, company): the token table carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half migrations), so one connection leaked "connected" onto every company the user belongs to, sync ran the token against the wrong orgnr (behorighet 403), and reconnecting from another company silently moved the row and went dark on the first company's crons. Token reads/writes are now scoped by company through the whole chain (token-store, api-client refresh coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP), /skattekonto/saldo answers 401 NOT_CONNECTED for companies without their own row (which is what the page's startkort keys on), and the dashboard connect-nudge counts only the active company's row. Bookkeeping's pristine start card now keys on all-years emptiness via a count probe instead of "no active filters": the default fiscal-year selection counted as a filter, which made the card unreachable on brand-new companies (it showed "inga traffar" instead). Two browser-global localStorage keys become company-scoped with legacy fallbacks: the inbox onboarding dismissal (dismissing on one company hid the card everywhere) and the periodisering auto-detect toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scoping): dedupe cron work per (user, company) + guard the ledger probe CodeRabbit findings on #1610: the skattekonto sync cron still deduped token rows by user_id alone, which would drop every company but one for multi-company operators (the exact scenario the PR fixes); and the all-years ledger probe could leave a stale false behind on a failed refetch, letting the pristine card render unconfirmed. The probe now resets to unknown in flight and carries the fetch generation guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
07e89d9b52 |
feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation * fix(invoices): harden Peppol compliance guards * fix(api): narrow Peppol document loading * test(pg): hash Peppol fixture payload * fix(invoices): address Peppol review findings * test(pg): isolate Peppol provider events * test(pg): isolate Peppol submission fixtures |
||
|
|
7881f757a9 |
feat(bookkeeping): show who committed a verifikat, and mark agent work in Granskning history (#1591)
Flows build plan prereq 3 (provenance display). Pure UI over columns that
have existed since migration 20260619120000:
- types: JournalEntry gains committed_actor_type/committed_actor_label
(the detail/chain APIs already select('*'), the type just lacked them)
- voucher detail: new "Bokford av" row in the Details card, derived from
actor type + credential label, with the Bot mark for non-user actors
- Granskning Historik rows get the same actor circle pending rows have
(Bot vs ClipboardCheck) so agent-originated history reads at a glance
- run-turn: correct the staged_operation params comment (tool-use input
is a superset of pending_operations.params, not the same values)
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
857dd575d0 | fix: harden kontantmetod year-end cutoff (#1592) | ||
|
|
73c63209f1 |
feat: stage kontantmetod year-end cutoff (#1586)
* feat: stage kontantmetod year-end cutoff * fix: keep cutoff tool payload searchable * fix: trim year-end tool metadata |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
f776e375c5 |
feat(payments): supplier payment batch schema + pain.001 domain lib (betalfil 1/3) (#1500)
* feat(payments): supplier payment batch schema + pain.001 domain lib Betalfil for leverantorsfakturor, part 1 of 3. New tables supplier_payment_batches + supplier_payment_batch_items (RLS, immutable item snapshots, FK RESTRICT on invoices), payee/reference resolution, eligibility rules shared by preview and create, and a supplier-dialect pain.001.001.03 generator (SESBA 9900 BGNR / 9960 BBAN / clearing BBAN, SCOR for Luhn-valid OCR, Ustrd fallback, no SvcLvl/CtgyPurp). Deterministic regeneration: msg_id derives from the batch id, CreDtTm from created_at, so re-downloads are byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(payments): use lib/money helpers instead of raw ore rounding The naive-ore-round ratchet flags new Math.round(x*100)/100 sites; roundOre/sumOre/ORE_TOLERANCE are the sanctioned forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): classify batch tables in full-archive contract + fixture The no-phantom-columns contract requires every company-scoped table to be triaged in full-archive-export; the batch rows are underlag for the payments they initiated, so they dump with the archive. makeSupplier gains the clearing/account columns the Supplier type now carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): harden batch integrity per review Composite (id, company_id) FKs so items can never cross-link a batch and an invoice from different companies; BEFORE UPDATE trigger keeps batches immutable outside lifecycle + download metadata and one-way on created -> cancelled; active-batch lookup now fails closed (an error no longer reads as no active batches, which would have silently disabled the duplicate-batch guard); today derives from Europe/Stockholm, not UTC; pain.001 control sums add the amounts as rendered so CtrlSum always equals sum(InstdAmt); event-bus reset in test hooks; Danske LB date claim in DECISIONS verified against the primary page (the bot's 12 May date is the alias-initiation date, not LB retirement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): bind cancellation metadata to the cancel transition cancelled_at/cancelled_by may only be written by created -> cancelled; cancelled_by may still become NULL so the FK's ON DELETE SET NULL keeps working when the cancelling user's account is deleted (proven in pg). 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> |
||
|
|
c77367ac1a |
feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1490)
* feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1444) Migrations 20260806150000/160000 let backend writers (service_role, no-claims direct SQL) preserve a preset committed_at on the draft-to-posted transition, but nothing recorded that the transition timestamp was overridden: BFNAR 2013:2 kap 8 wants a log of who did what, when. If a trusted-role connection is ever used against a live company, there is now an audit row separating real from preset commit time. - audit_log accepts action COMMITTED_AT_OVERRIDE (types + Zod filter too) - log_committed_at_override() SECURITY DEFINER writer: audit_log has RLS with no INSERT policy and service_role is not guaranteed BYPASSRLS in every harness; EXECUTE revoked from anon/authenticated so PostgREST cannot expose it as an RPC - set_committed_at() calls the writer in the preserve branch; the stamping branch is unchanged from 20260806160000 - pg tests: override row content (preset value, wall clock, jwt role) for postgres and service_role writers, absence on the stamp paths, and the writer's privilege lockdown Closes #1444 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review(audit): stamp wall_clock with clock_timestamp(), not now() Seeding flows post many entries inside one transaction; now() would pin every override row's wall_clock to the BEGIN instead of the actual transition moment. Captured once so new_state and description agree. Test posts inside an explicit transaction after pg_sleep and asserts wall_clock moved past the transaction start. 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> |
||
|
|
38f5d9812e |
feat(receipt-hunt): find receipts in connected mailboxes and pair them on the amount (#1492)
* feat(receipt-hunt): nightly matcher pairing unbooked purchases with held receipts Stages an attach_document_to_transaction proposal for every unbooked card purchase whose receipt the company already holds, so the underlag is attached before the transaction is booked and the gap never forms. When the user later books it, categorize-core.ts propagates the document onto the new verifikat through the matched_transaction_id link the executor writes. Deliberately scoped to UNBOOKED transactions. The posted-verifikat backlog is 96% imported history whose originals live in the previous system, so it stays a pull (the verifikat_missing_document worklist) rather than a nightly push. Ranking reuses scoreUnderlagCandidates; the pool is loaded once per company instead of per transaction, which removes both the N+1 and the newest-50 truncation a per-transaction lookup imposes on a deep backlog. Five guards, each mutation-tested: a confidence floor above the shared candidate floor, an ambiguity margin so two equally-good receipts are left to the picker rather than coin-flipped, one-receipt-one-purchase, one live proposal per purchase, and permanent suppression of pairs a human rejected. Suppression is derived from pending_operations history rather than a new table: terminal rows are immutable and a rejection is already the durable "no". Runs 05:30 UTC, after the 05:00 bank sync. Gated on RECEIPT_HUNT_COMPANY_IDS, which hunts nobody when unset so enabling it stays a deliberate act. No migration, no journal writes, no UI: proposals land in the existing Granskning queue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(receipt-hunt): dry-run mode for provkörning against a real ledger Returns the pairings a run would stage without writing any of them, so a company can see tonight's proposals before they reach the granskningskö and so the matcher can be validated against production data without staging an operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(matching): fold Swedish bank descriptors so receipts reach their purchases calculateMerchantSimilarity compared raw bank descriptors, so a receipt from "Alviks kött och fisk" scored 0.125 against the bank's own row for it, "Alviks koett och fisk K3667 Kortköp/uttag" — an öre-exact pair no threshold could reach. Adds normalizeForMatch, used for similarity only, which folds what the card rails add and never changes identity: the K#### token, Kortköp/uttag verbs, a leading "Kortköp YYMMDD", trailing /YY-MM-DD dates, reference numbers glued to the name, domain wrappers, legal forms, and the three ways banks mangle Swedish letters (ö, transliterated "oe", and ?? mojibake). Processor markers become spaces because the merchant sits before the star in GOOGLE*PLAY and after it in K*IKEA GALLE. Token-subset containment is scored level with substring containment so a receipt's legal name matches the bank's trading name. normalizeMerchantName is left byte-identical and now documents why: it is a transitive input to categorization_templates.counterparty_name, a persisted UNIQUE key with a hand-written SQL mirror the ledger-context RPC recomputes at query time. Changing it would make stored keys stop equalling computed ones, so the konteringskarta join misses and insertOrUpdateTemplate inserts a second row per merchant instead of migrating the occurrence counts. Aggressive folding is safe because it is applied to both sides of every comparison, so an over-eager fold still matches; the risk is collision between different merchants, which the new tests guard. Measured on 27 receipt/transaction pairs humans actually confirmed in production: recall 27/27, and 0/7 false positives on deliberately similar but distinct merchants. Full unit suite unchanged (13,004 passing), including the 22 string pins on the frozen key path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mail): read-only Gmail connector so receipts are found without forwarding Forwarding was the only way a receipt reached Accounted, and it is both unpopular (97% of companies with the problem have never used their inbox address) and fragile: Arcim's own forward has been off for weeks and nobody noticed. This lets the hunt look in the mailbox instead. Scope is gmail.readonly and nothing else. It can search and download attachment bytes, and it structurally cannot send, modify or delete: the promise the consent screen makes is enforced by the grant, not by our code being careful. The consequence is deliberate: the agent can prepare a forward for a portal-link receipt but can never send one itself. Query-then-classify, never sync. For each unexplained purchase we run a provider-side search in a -3/+10 day window, pull metadata for a handful of hits, and keep nothing. No mailbox is mirrored and no message body is stored, which is what keeps this inside Google's Limited Use terms and GDPR data minimisation. Mail is searched only for purchases Underlag could not already explain, so a receipt we already hold never costs a mailbox read. The query ORs merchant against amount rather than requiring both: demanding both misses every rebrand and reseller (Anthropic bills as Claude), while the amount alone is a strong filter inside two weeks. mail_connections is service-role only with RLS enabled and zero policies, because the row holds a live refresh token and RLS cannot hide a column. Uniqueness is (company, provider, address) so a second mailbox is additive and a reconnect updates in place. Tokens are AES-256-GCM under their own key by preference, since a mail grant reads correspondence rather than backups. Core reaches the extension through a registered service, mirroring lib/email/service.ts, so lib/receipt-hunt never imports from @/extensions and a zero-extension build still compiles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mail): connect UI and ingest, making the hunt reach into the mailbox Two halves that together make the connector usable. Ingest (lib/receipt-hunt/ingest.ts, core): fetches the attachment, files it as a document and an inbox item with source 'mail_hunt', then stages the pairing. It lives in core because it writes documents and inbox items, and an extension may never import another extension; the mail extension only ever hands over bytes. No re-matching for a hunted receipt: it was fetched WHILE SEARCHING for a specific purchase, so the pairing is known by construction. The search is a deliberately broad OR query, which is exactly why the proposal still goes to a human with the mailbox, sender and subject written on it rather than being linked automatically. Provenance goes in channel_context, never extracted_data, because retrying extraction overwrites extracted_data wholesale and the record of which mailbox a receipt came from has to survive that. A partial unique index on (company_id, channel_context->>'mail_message_id') makes re-runs and the same receipt arriving in two mailboxes idempotent, and a 23505 is treated as success rather than an error. Guards, both mutation-tested: a duplicate message costs no provider call, and an oversized attachment is skipped rather than stored. One unreadable attachment falls through to the next and never aborts a night's hunt. UI: /settings/mail lists connected mailboxes with their health, connects a new one through a user-gesture tab (opened before the await, so popup blockers do not eat it), and disconnects behind a ConfirmDialog that states the outcome up front, including that already-approved receipts stay because they belong to the bookkeeping now. Strings in sv and en; the read-only promise is spelled out on the page rather than buried in a consent screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mail): renumber migrations to clear a version collision on main 20260806150000 was already taken by preserve_preset_committed_at, and woocommerce_connections plus enforce_balance_on_posted_insert landed after this branch was cut. Two files sharing a version breaks every fresh database, which only shows up on a clean setup rather than on an already-migrated one. Applied to prod under the new versions (20260807090000 / 20260807090100), so schema_migrations matches these filenames exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(receipt-hunt): make the mailbox search actually able to find an underlag A provkörning against a real ledger returned the same seven unrelated messages for every purchase, all reporting no attachments. Three separate causes, each fixed and pinned: 1. `getMessageSummary` asked Gmail for `format=metadata`, which returns headers and omits `payload.parts` entirely. Every message therefore looked attachment-free, `bodyIsReceipt` was always true, and the `found.find(c => c.attachmentIds.length > 0)` guard in the hunt could never select anything: the feature could not file a single receipt. Gmail has no format that returns MIME structure without the body, so the body now comes down the wire; it is read for nothing and stored nowhere. 2. The bank's description is not a merchant name. "Lön Juli Jakob Överföring via internet" searched for "Juli" and matched most of the mailbox. Month names and payment-rail boilerplate are now stopwords. 3. Salary and tax runs are a company's largest outgoing rows, so they consumed the whole search budget hunting receipts that cannot exist. `canHaveEmailReceipt` skips them for the mail leg only. Deliberately narrow: a supplier invoice paid over bankgiro does arrive by mail, and an "Utlägg" reimbursement has a real receipt behind it. Measured on the same ledger: 22 hits, 0 with attachments, 0 ingestable -> 4 hits, all with attachments, 3 of 4 correct (Elgiganten, Sting, Anthropic). The fourth matched a Stockholm billing address, which is why every proposal still waits for a human. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(receipt-hunt): let a model resolve merchants and pick the receipt The keyword hunt was failing for reasons regex tuning cannot reach, all measured against a real mailbox rather than assumed: - `from:anthropic.com` returns 0. Receipts arrive here by being forwarded, so the sender is the user, not the vendor. - The exact charged amount returns 0. The bank posts a converted SEK figure that appears nowhere in a USD receipt. - A date window around the purchase returns 0, while the same merchant search without one returns 10+. A forward is stamped when it was forwarded, sometimes months later. So the query now searches merchant names across the whole mailbox, and precision is restored by judgement rather than by syntax. Two model calls per run, both through forced tool use so the reply is a shape and not prose to be parsed: 1. `planMerchantGroups` resolves bank descriptors to merchants and merges repeats. Six Anthropic subscriptions become one search and one decision instead of six of each. 2. `assignReceipts` decides which mail, and which attachment on it, is the receipt for which charge, and says why in a sentence the reviewer reads. The attachment, not the message, is the unit of an underlag: a single forward routinely carries receipts for several purchases ("Fwd: Kvitton februari" has five). Migration 20260807103000 moves the dedupe key from message to message+attachment, with a backfill, because the old index would have silently blocked every receipt after the first in a forward. The model may not produce any number that reaches the ledger. It returns ids, a confidence and a reason; amounts, dates and the write stay in deterministic code. Its answer is validated, not trusted: an unknown message id, an invented filename or a low confidence drops the pairing, and any failed call proposes nothing at all. Every result still waits for a human. Measured on the same ledger: 0 receipts that could ever be filed -> 3 correct pairings (Elgiganten, Sting office invoice, Anthropic), each with a stated reason. The five remaining Anthropic charges are dated after 2026-06-15, when forwarding to the connected mailbox stopped; the model declined them correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(receipt-hunt): amount first, and drop the confidence scoring Three findings from how others build this, applied. Production email search (Superhuman, Haystack 2026) reports that recall comes from loosening retrieval and letting the model filter downstream, not from tightening the query. Retrieval depth per merchant 12 -> 25, and purchases the planner cannot name a merchant for are now searched by amount alone instead of skipped: a line like "1260525758758 Europabetalning" identifies no merchant but is a real supplier payment whose invoice may carry exactly that total. Reconciliation engines weight amount far above date (Midday: 35% vs 5%) because banks post late while amounts do not drift. The Gmail query now leads with the amount and ORs the merchant, rather than dropping the amount whenever a merchant alias exists. Still an OR: a receipt billed in USD never contains the SEK figure the bank charged. The confidence score is gone entirely. Research on verbalised confidence finds it badly calibrated, clustered on round-number anchors and barely better than chance at separating a model's own right answers from its wrong ones. That matched what this ran into: the model anchored on 0.6 / 0.7 / 0.75 / 0.9, and the 0.7 threshold discarded two correct pairings. It is replaced by an observation rather than a self-assessment, whether the charged amount is actually visible in the mail, which is what a reviewer checks first and what sorts the queue. Also fixes a real defect the run exposed: the one-file-one-purchase guard only held within a merchant group, so when the planner split one landlord into "Sting" and "Kontorsplatser" both 15 000 kr charges were assigned the same invoice. A file is now claimed once per run, which is the duplicate underlag BFL forbids. Measured on the same ledger: 3 -> 5 pairings, no duplicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(receipt-hunt): harvest receipts, then pair them on the amount Splits the mailbox leg in two along the line of what each side can actually know. The model was being asked which purchase a mail belonged to. Deciding that needs the amount; the amount lives inside the PDF; a Gmail preview essentially never shows it. Measured over a real mailbox, every single pairing came back "belopp ej synligt": it was answering without the deciding evidence, which is why it declined five of six repeat subscriptions and why two correct pairings sat just under a threshold. Now it answers only what a subject, a sender and a preview line support: is this mail an underlag, and which attachment is it. Then the receipt is fetched, the extraction that already runs on document.uploaded reads its amount, date and vendor, and the pairing is the same deterministic amount-and-merchant match every other underlag goes through. Amount becomes decisive for real rather than as an instruction the model could not act on. The load-bearing fix is small: ingest now copies the extraction result onto the inbox item. The pool is read from invoice_inbox_items, so a hunted receipt with no extracted_data could never have matched anything, and the whole mail leg was quietly incapable of producing a pairing on amount. Consequences, all deliberate: - Harvesting runs BEFORE the pool is read, so a receipt found tonight is paired tonight rather than a night later. - One staging path instead of two. Mail-sourced proposals carry the same preview and confidence as every other, plus where they came from. - Deduped on the attachment filename, not on the message: the same invoice arrives as an original, a reminder and two forwards, and the old key filed "Invoice_13041840.pdf" four times over. - Capped at 8 receipts per merchant per run. Measured on the same ledger: 5 pairings attempted from thin evidence -> 16 real documents identified, each waiting on an amount it can be checked against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(receipt-hunt): the model reads mail, arithmetic does the matching Collapses the mailbox leg to one model call that extracts fields, and hands every judgement back to deterministic code. Gone: resolving bank descriptors to merchant names, deciding which mail belongs to which charge, and the confidence score gating the result. Three prompts and two model calls become one, and mail-intelligence.ts drops from 450 lines to 250. What made this possible was measuring what a mail actually contains. The body was being downloaded and thrown away in favour of a 200-character snippet, and the body is where a forwarded receipt quotes its original sender and its original date. That is the purchase date, the thing whose absence forced the date window off entirely and made the old design miss five of six repeat subscriptions. It was there all along. So the model now answers only what text can support: is this an underlag, from whom, when, and for how much if the mail says so. Fields, not judgements. Everything after is arithmetic: - Retrieval is deterministic. No model decides what to search for. - Fetching is gated by worthFetching(): a stated amount is enough on its own, a vendor needs a plausible date, and a mail found by a purchase's own search is evidence in itself. That last rule is what handles a supplier the bank and the invoice name differently ("Kontorsplatser j BG" against "Stockholm Innovation & Growth AB"), which is what the deleted merchant-resolution call used to buy. - The pairing is the existing scorer, reached the same way as every other underlag: fetch, let the extraction that already runs on upload read the PDF, match on the amount. Amount is decisive in fact rather than as an instruction the model could not act on. Also adds the Swedish thousands-space amount formats to the query. Measured: the Sting invoice is findable as "15 000,00" and "15 000" and by no ungrouped form at all, so every amount search was missing them. Measured on the same ledger: 5 thin pairings -> 8 real documents, each with a vendor and a true purchase date, waiting on the amount in its own PDF. Currency is never converted to make a number agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(receipt-hunt): trust the bytes, not the mail, when filing an attachment Found by the first live run, which fetched nothing and reported success. Three defects, each invisible to a dry run because a dry run never downloads anything. 1. Gmail declares a forwarded PDF as application/octet-stream, and uploadDocument validates content against the declared type, so the upload was rejected: "Filinnehållet matchar inte den angivna filtypen". Every forwarded receipt with a generic MIME type would have failed this way, silently, since ingest swallows one bad attachment to protect the rest of the run. The type is now sniffed from the magic bytes, then the filename, and only then from what the mail claimed. 2. The filename was re-derived by a second full message fetch inside fetchAttachment, which came back empty and fell back to a generic "underlag.pdf", discarding the real "2332687551.pdf" the search had already reported. The known name now wins. 3. The provkörning script imported lib/init instead of calling ensureInitialized(), so document.uploaded reached no handler and nothing was ever extracted. It also used static imports, which are hoisted and ran before .env.local was read, leaving the extraction extension unable to build a Supabase client. Both are script defects, not product defects: the cron route calls ensureInitialized() at module level as the architecture requires. The script now loads the environment first and imports dynamically. Also makes the per-run fetch cap tunable (RECEIPT_HUNT_MAX_RECEIPTS) so a pilot can be held to a couple of documents, and adds --live to the script, which is the only way it writes anything. Verified end to end against a real ledger, every link exercised for the first time: two attachments fetched from Gmail, stored with their real names and types, extraction run on both, the amount copied onto the inbox item, and the deterministic matcher pairing Elgiganten 21 639,00 kr from the PDF against the -21 639 kr card purchase at 0.85, staged into Granskning as attach_document_to_transaction. The second document, a Bolagsverket filing receipt, carries no total and correctly paired with nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(receipt-hunt): sweep a whole mailbox, and stop lending one receipt twice A backfill on a real ledger, 22 documents fetched from 172 messages. Batches the extraction (25 mails per call) so a first run on an existing company can read the whole mailbox instead of the 40 mails one call can carry, and makes the per-run caps tunable (RECEIPT_HUNT_MAX_MAILS, RECEIPT_HUNT_MAX_RECEIPTS) so a pilot can be bounded. The nightly caps stay where they are: they pace the review queue, and a backlog is a different job from a nightly tick. Two defects the backfill exposed, neither reachable from a dry run: The one-receipt-one-purchase rule only held inside a single run. `spentDocumentIds` is per-invocation, so an H&M receipt was proposed against a -358 kr purchase on one pass and a -354 kr purchase on the next, and approving both would have put the same underlag on two verifikat. A live proposal now claims its document across runs, the same way it already claimed its transaction. A document reported with no filename, on a message carrying five attachments, was not an answer but a shrug: the caller fetched attachment number one and hoped. Those are dropped now. A body-only receipt, where there is nothing to choose between, still passes. Measured after the sweep: 21 of 22 documents read correctly, and the binding constraint on this ledger is no longer retrieval but currency. Ten receipts are in SEK and five of those pair on the amount; twelve are in USD or EUR, where the bank charged a converted figure that appears nowhere in the receipt, so no comparison is possible and none is attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mail): show the provider's own mark on the mailbox settings page Someone connecting a mailbox is picking an account at a provider, and the provider's mark is how they recognise which one. A generic envelope glyph said "mail" when the question is "whose". The Google "G" already existed, drawn inline inside GoogleAuthButton for the sign-in flow. It moves to components/ui/provider-marks so there is one definition rather than two, and a Microsoft square joins it for the Graph connector. Both stay inline: no external host is contacted for an icon before anyone has agreed to anything. These are the only coloured glyphs in an achromatic interface, which is deliberate rather than an oversight. A brand mark is identity, not chrome, and Google's terms require its mark unaltered rather than tinted to match a palette. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(archive): drop the duplicate mail_connections exclusion left by the rebase Main added the table to ARCHIVE_EXCLUDED_TABLES while this branch was open, so rebasing produced the key twice and the zero-extension build failed to type check. Main's entry stays, in its alphabetical place, and keeps the sentence that answers the retention question: the grants are not räkenskapsinformation, but the receipts they find are archived as documents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mail): record who disconnected a mailbox, without keeping the token Raised by the compliance review: disconnect() hard-deleted the row with no trace, and which mailboxes feed underlag into the books is a control over how räkenskapsinformation is produced (BFNAR 2013:2 kap 8), so switching one off should be reconstructable years later. Written by hand rather than by the write_audit_log trigger the accounting tables use. That trigger copies the whole row into audit_log, which here would mean copying an encrypted refresh token into a second table and keeping it after the entire point of the delete was to destroy it. The sibling credential table shopify_connections omits the trigger for the same reason. Only the address and provider are recorded, pinned by a test that fails if a credential ever reaches the audit entry. The review's two other flags were checked rather than assumed: nothing purges mail_hunt documents, and categorize-core.ts:403 does carry the attached document onto the verifikat when the transaction is booked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mail): bound every outbound call, and stop the token widening itself Four findings from the review, each checked against the code first. Neither the Gmail API nor Google's token endpoint had a deadline. Both are awaited inside Promise.all across mailboxes, so one stalled request held the whole company's hunt open until the platform killed the run. Both now carry a 15s AbortSignal, which turns a stall into one mailbox missing from tonight's sweep. `include_granted_scopes: 'true'` let Google fold scopes this app was granted elsewhere into the token issued for a mailbox, so a grant could carry more authority than the consent screen showed. Removed, and pinned by a test asserting the parameter is absent. disconnect() ignored both statement results: a failed delete still wrote an audit entry claiming the mailbox was disconnected while the credential was live, and a failed audit insert passed silently. The delete now throws, so the entry is never written for a delete that did not happen. The audit failure is logged rather than rolled back: the two can now only diverge one way, credential gone and note missing, and recreating a credential to keep them in step would be worse than a missing note. The fifth finding is real and stays open by choice, recorded in DECISIONS.md: the cron still passes searchMail=false. A sweep of one 172-message mailbox took over 600s against a maxDuration of 300, so enabling the mailbox leg nightly would time out mid-run. That flag and RECEIPT_HUNT_COMPANY_IDS get flipped together once the per-company budget is measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(receipt-hunt): file each attachment under its own identity Four more findings from the review. The first is a real defect. ingestMailCandidate loops over candidate.attachmentIds, but the dedupe key, the mail_attachment_id provenance and the filename were all read from index 0. Storing the second attachment therefore recorded the first one's key and name, which mislabels the row and, because the key is unique, permanently blocks the first attachment from ever landing. Masked today only because the hunt narrows to a single attachment before calling in, so nothing in the current path exercises it. All three now come from the attachment actually being stored, and the duplicate pre-check moved inside the loop so trying a second attachment is not suppressed by the first already being filed. Mutation-tested. The per-run fetch key was the bare filename, which is not an identity: "invoice.pdf" is what half the world's billing systems attach, so a second supplier's invoice would be dropped as a duplicate of the first. Scoped by vendor as well, keeping the behaviour it was written for, one fetch for an invoice that arrives as an original, a reminder and two forwards. Adds tests/pg/mail-hunt-file-dedupe.pg.test.ts for the new unique index: five attachments from one forward all land, the same attachment is refused twice, two companies hold the same file independently, other inbox sources are untouched by the partial predicate, and the message-scoped predecessor is gone. Written against CI's Postgres; there is no local DATABASE_URL here, so CI is what exercises it. --live now refuses unless RECEIPT_HUNT_CONFIRM names the same company. The script writes to whatever .env.local points at, which for this repo is production, and a recalled command should not be able to fire it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): cast the jsonb parameter so Postgres can type it pg-real could not determine the type of $3 inside jsonb_build_object. An explicit ::text is what the other pg tests do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c187fabf92 |
feat(shopify): Shopify order/refund feed into the transactions inbox (#1474)
* feat(shopify): Shopify order/refund feed into the transactions inbox
New extensions/general/shopify feed extension, modeled on the WooCommerce
feed: connect a Shopify store with Dev Dashboard custom-app client
credentials (client credentials grant, ~24h tokens, never stored), then a
nightly cron + manual sync imports paid orders and refunds via the GraphQL
Admin API (pinned 2026-07) into the transactions inbox on clearing account
1584. Feed-only: nothing auto-books. Zero PII fields are queried, keeping
the app outside Shopify's protected customer data program.
- shopify_connections migration (RLS, revoke-never-delete, encrypted
client id/secret) + shopify_sync capability and bank_sync-mirrored
backfill
- frozen external_id scheme shopify_{shop_domain}_order|refund_{id},
scoped on the shop domain so reconnects never re-import
- cursor sync on updated_at windows with 24h overlap, lock-date drop at
map time, ingest-failure cursor floor, deadline stop-and-resume,
revoked-credential flip
- /import card + settings panel, sv/en i18n, cron 03:15 in vercel.json +
regenerated Docker crontabs, logo, events, panel registry
- 65 unit tests + pg-real RLS test; extensions.schema.json enum also
gains the missing stripe entry (pre-existing drift)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shopify): review findings from PR 1474
- token exchange: a 429 that survives every retry is throttling, not a
credential failure; stop remapping retryable 4xx to 401 so sustained
throttling can no longer flip the connection to revoked and delete the
stored credentials (CodeRabbit critical)
- order sync: advance a scanned-through watermark (run start, capped by
the failure floor) after a fully-listed window, so empty first runs and
quiet stores rotate to the back of the cron's oldest-first selection
instead of permanently occupying the 50-connection batch (CodeRabbit
major, starvation)
- add handler-level tests for the orders cron route (auth 401, disabled
503, unconfigured no-op, query failure, capability skip, happy path,
per-connection failure isolation, revoked marking)
- add 401 tests for /sync, /transaction-sync and /disconnect; pin the
cursor floor rule with a two-order page; stub the encryption key via
vi.stubEnv
- note in the panel description (sv/en) that orders can mix VAT rates and
must be split at booking (Swedish review advisory)
- DECISIONS.md: wrap underscore identifiers in backticks (MD037)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
70845edf69 |
feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name
Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:
- transactions.transaction_method (text + CHECK closed vocabulary: card,
transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
bank_transaction_code / proprietary_bank_transaction_code evidence
columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
explicit source method (Stripe txn.type) > trailing Swedish channel
phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
as original_description; dedup is untouched (external_id is date+öre,
the content bridge reads original_description and is prefix-based, and
a trailing strip leaves a prefix). Enable Banking passes the codes
through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
(+ MCC and Stripe prefixes) and strips unedited titles; user-edited
titles are never rewritten.
- mapping-engine also matches original_description so user rules written
against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
"Betalsätt: Kortköp" etc. (sv/en), making every classified row
expandable.
A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): anchor counterparty-template identity on original_description
Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.
Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.
Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): review follow-ups on method classification
- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
refinement on the proprietary code beats a bare family match on the
ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
that only match original_description, including the invalid-regex
substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
mapping to 'fee'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): scope method classification to feed rows + adjective guard
Multi-bank risk hardening before the backfill ships:
- Feed-row scope: classification and title stripping now require a real
import feed (import_source present, not manual/mcp), both at the
ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
every backfill statement. User-authored titles like "Egen insättning"
on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
is skipped, so "Egen insättning" stays whole even on bank-feed rows;
the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
phrase means no method and no rewrite, so the worst case for any bank
whose vocabulary we have not seen is the status quo.
Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations after rebase
Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): bump transaction_method versions past prod's max
Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker
- TransactionInboxCard: row-level Enter/Space handling now ignores events
bubbling from nested controls, so keyboard activation of Bokför / the
overflow menu is no longer cancelled by the (now much more common)
expandable row.
- Feed predicate parity with isImportedTransaction(): a live
bank_connection_id marks a feed row even when import_source is unset
(the oldest PSD2 rows predate that column), in both the ingest
classifier and every backfill statement: those legacy rows now get
classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail
Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): enforce, not assume, original_description preservation in the title strip
The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the compliance-review triage of the backfill's booked-row title strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
|
||
|
|
c912fc5633 |
fix(sandbox): delete api_keys and retag log in teardown (final backlog blockers) (#1454)
The last 9 backlog users decompose into exactly two classes: api_keys.sod_acknowledged_by (NO ACTION to auth.users) blocks every keyed sandbox, and the WORM dimension_retag_log blocks any sandbox where a voucher line was retagged. cleanup_sandbox_user now deletes the user's api_keys explicitly (they must die with the sandbox anyway) and the company's retag log under the standard bypass; dimension_retag_log_immutable's DELETE branch honors gnubok.sandbox_cleanup with the same per-row sandbox re-verification as every other guard, UPDATE stays blocked. Fixture seeds both row types. Validated end-to-end on staging. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
526f0315d0 |
fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers (#1452)
* fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers Draining the prod backlog exposed two final issues: - The function-level statement_timeout shipped in 20260807150000 does NOT lift authenticator's 8s cap: the timer arms when the top-level statement starts (verified empirically on prod: SET LOCAL 2s canceled the RPC despite its 290s proconfig; matches the 2026-08-04 SIE-import finding). The route now loops batches of 10 (~220ms/user with the account_id index, so ~2.2s per batch), each rpc() call being its own statement with its own 8s window. The loop stops when a batch makes no progress or the 240s time budget nears; capacity is 250 users/night. - processing_history.company_id and invoice_deliveries.company_id are plain NO ACTION FKs, so sandboxes whose visitor produced AI telemetry or sent a demo invoice could never be deleted (7 of ~510 backlog users). A data-driven sweep of every NO ACTION FK into companies confirms these two plus the already-handled audit_log are the only such tables with sandbox rows. cleanup_sandbox_user (migration 20260807160000) deletes them explicitly; the pg fixture now seeds a processing_history row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): use valid processing_history aggregate_type/event_type in sandbox fixture aggregate_type is CHECK-constrained and event_type is an FK to the seeded processing_event_types lookup; the guessed values failed all five fixture-dependent pg tests in CI. Validated against staging: Document/DocumentIngested inserts and tears down cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): bypass invoice-delivery delete guard in teardown, cover both blocker tables in pg fixture CodeRabbit's fixture ask exposed a real gap: enforce_invoice_delivery_immutability silently swallows DELETEs (RETURN NULL plus a SECURITY_EVENT audit row) for terminal rows, so the explicit invoice_deliveries delete was a no-op and the companies FK still blocked teardown for sandboxes that sent a demo invoice. The trigger's DELETE branch now honors the gnubok.sandbox_cleanup flag with the same per-row sandbox re-verification as every other guard; base definition 20260803224000, all other branches untouched. The pg fixture seeds an invoice plus a marked_sent manual delivery, and a new test pins the zero-settings refusal path the Swedish review asked about. Validated on staging end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bd7a423b86 |
fix(sandbox): fit the nightly cleanup inside PostgREST's 8s session cap (#1451)
* fix(sandbox): fit the nightly cleanup inside PostgREST's 8s session cap Follow-up to #1449. Profiling the repaired teardown on prod puts one sandbox user at ~3s (the auth.users delete fans out over ~250 FK triggers; FK indexes were tried inside an aborted transaction and do not help), while every PostgREST session inherits authenticator's statement_timeout = 8s. The nightly RPC call therefore times out and ROLLS BACK wholesale: a second silent-failure mode for the same cron. - cleanup_expired_sandbox_users gets a function-local statement_timeout of 290s (same sanctioned pattern as undo_sie_import, 20260702154500) via migration 20260807150000. - The cron route bounds each night to 60 users (~180s), exports maxDuration = 300, and the backlog drains over a few nights. - Tests: route asserts the bounded rpc call and the maxDuration budget; the pg suite pins proconfig containing statement_timeout=290s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): index journal_entry_lines.account_id (chart-account cascade seq-scans 730k rows) Caught live during the backlog purge: DELETE FROM auth.users cascades chart_of_accounts deletion, whose ON DELETE SET NULL fires an unindexed UPDATE over journal_entry_lines per account (~37 per sandbox company). This is the bulk of the ~3s per-user teardown cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f7f3a31f8e |
fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs (#1449)
* fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs
The daily cleanup cron has deleted nothing for months: cleanup_sandbox_user
died on the journal-line immutability trigger for every user (the seed posts
vouchers since spring), and cleanup_expired_sandbox_users swallowed each
failure as a WARNING while reporting success. 658 expired sandbox users plus
21 orphaned anonymous users had accumulated in prod auth.users.
- cleanup_sandbox_user sets the sanctioned gnubok.allow_delete flag plus a
new transaction-local gnubok.sandbox_cleanup flag, only after verifying
is_sandbox; write_audit_log, audit_log_immutable (DELETE only, per-row
sandbox re-check), enforce_dimension_registry_guards (DELETE only) and
enforce_pending_operations_no_delete (DELETE only) respect it
- clears salary_runs voucher-link FKs and purges the sandbox company's
audit rows before the auth.users cascade
- cleanup_expired_sandbox_users returns {cleaned, failed, orphans_removed},
additionally sweeps expired anonymous users that never got a
company_settings row, and takes an optional p_limit for bounded batches;
the cron route logs failures at error level and accepts both return shapes
- both RPCs lose their default PUBLIC EXECUTE grant (anon and authenticated
could call them via PostgREST) and are now service_role-only
- validated by replaying the full delete chain against prod inside aborted
transactions (21 users sampled across all seed eras, zero failures) and a
committed staging run; pg-real suite + cron route unit tests added
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): per-row sandbox re-verification in teardown guards, is_anonymous column guard
Resolution pass for PR #1449 review findings and the pg-real CI failure:
- Swedish accounting review: enforce_dimension_registry_guards and
enforce_pending_operations_no_delete now re-verify per row that
OLD.company_id belongs to a sandbox company (same pattern as
audit_log_immutable) instead of trusting the gnubok.sandbox_cleanup flag
alone. Because that re-check needs company_settings to still exist,
cleanup_sandbox_user deletes pending_operations and dimensions explicitly
before the auth.users cascade.
- pg-real CI: auth.users.is_anonymous does not exist in the CI
supabase/postgres image (or on older self-hosted stacks); the orphan sweep
in cleanup_expired_sandbox_users is now guarded on the column's existence,
and the pg test skips the orphan assertions on such stacks.
Re-validated on staging end-to-end: {cleaned: 5, failed: 0,
orphans_removed: 1}, fresh users and non-sandbox rows untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): make company_settings.is_sandbox write-once, prove orphan sweep fails loudly
Round-2 review findings (Swedish accounting review on PR #1449):
- Every teardown bypass trusts company_settings.is_sandbox, and RLS lets an
owner update their own settings row via PostgREST, so a real company that
flipped the flag would become eligible for full deletion by the nightly
cron. New trigger makes the flag write-once (no application path updates
it; a future sandbox-to-real conversion would ship its own migration).
- New pg test pins the reviewer's remaining concern: an anonymous user who
somehow has bookkeeping but no company_settings row is NOT silently
deleted by the orphan sweep; the unbypasseed immutability triggers make
the deletion fail loudly into the summary's failed count.
Validated on staging: flip blocked in both directions, unrelated
company_settings updates unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): guard is_sandbox provenance at INSERT, make orphan sweep exclusions explicit
Round-3 review hardening, approved by Emil:
- is_sandbox = true can now only be created by an anonymous-user JWT (the
sandbox seed's actor), service_role, or a direct database session. A
regular authenticated user could previously insert their settings row
pre-flagged and have the nightly cron destroy their real books, which
BFL 7 kap. forbids even self-inflicted. Claims are read from the
request.jwt.* GUCs directly so the check behaves identically on hosted,
self-hosted, and the CI auth shim.
- The orphan sweep now explicitly excludes anonymous users attached to any
companies or company_members row, instead of relying on downstream
immutability triggers throwing (emergent safety) to protect half-seeded
users.
- pg tests updated accordingly: blocked/allowed provenance paths, and the
half-seeded user is proven unreachable rather than merely failing loudly.
Validated on staging: authed insert blocked, anonymous-claim insert
allowed, half-seeded user untouched, sweep summary failed=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): all-rows sandbox check, cleared bypass flags, tighter insert guard
CodeRabbit review pass on PR #1449 (its first non-rate-limited run):
- cleanup_sandbox_user now requires EVERY company_settings row of the user
to be sandbox-flagged, not an arbitrary single row: a hypothetical
mixed-company user would otherwise have their real company's rows reached
by the user-scoped deletes.
- Both bypass flags are cleared before the RPC returns, so later work in
the same transaction (the expired loop's next iterations, the orphan
sweep) never runs with them still armed.
- The is_sandbox insert guard now treats ANY PostgREST claims context
(claims json without a role claim included) as guarded, instead of
falling open when the role claim is absent.
- The flag-leak pg test now runs inside an explicit transaction (the old
version could not observe transaction-local GUCs at all), and a new test
covers the mixed sandbox/real user refusal.
Declined: replacing the em dashes inside the two replicated Swedish
exception messages; they are byte-identical copies of the strings already
deployed by migration 20260702084500 and changing them would alter live
user-facing errors out of scope.
Validated on staging: mixed user refused, flags cleared post-teardown,
role-less claims blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
707d597b2e |
feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd344b6dbb |
fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries check_balance_on_post only fires on the draft-to-posted UPDATE transition, so any code path that INSERTs a row with status 'posted' directly skipped balance validation entirely. The invariant sum(debit) = sum(credit) on every posted entry was DB-enforced only for the engine's commit lifecycle. Add check_balance_on_posted_insert, a deferred constraint trigger on AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing check_journal_entry_balance() function, which already handles the journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics let an atomic transaction insert header and lines together; zero-line and unbalanced posted inserts are rejected at constraint evaluation. All existing checks stay intact; this only adds coverage. The one first-party posted-INSERT path outside an RPC, the sandbox seed, now books through the bookkeeping engine (createJournalEntry) instead of raw inserts. SIE import already inserts header and lines in a single transaction via its structured RPC and passes unchanged. pg tests cover the new path (zero-line rejected, unbalanced rejected at SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and existing posted-entry fixtures move to a transactional insertPostedJournalEntry helper so they stay valid setup. Fixes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): insert list-filters pg fixtures in one transaction The list-filters suite (landed via a sibling merge) inserted posted headers with getPool().query, where each query autocommits: the deferred check_balance_on_posted_insert constraint fired at the header's own commit with zero lines and correctly rejected the fixture. Header and balanced lines now share one BEGIN/COMMIT so the constraint evaluates the complete entry, mirroring the insertPostedJournalEntry helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(seed): insert journal headers as drafts, post after lines land check_balance_on_posted_insert (renamed to apply-time version 20260806130000) rejects a posted header whose transaction has no lines. PostgREST autocommits each request, so every seed path that inserted posted headers first would die with "has zero total": the sandbox seed (ledger history, invoice vouchers, salary vouchers), seed-demo-account and seed-export-data. All now insert draft headers, insert lines, then flip to posted so check_balance_on_post validates the finished verifikat. The sandbox seed keeps its documented no-events design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve a preset committed_at on draft-to-posted transition set_committed_at() stamped now() unconditionally, so the seed flows that post backdated drafts lost their historical booking timestamps and every demo verifikat read as booked today (CodeRabbit finding on PR 1439). Stamp only when committed_at is NULL: the engine path (drafts carry no committed_at) behaves exactly as before and a posted entry still always has a committed_at; an explicitly supplied value now survives posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve preset committed_at only for trusted roles The IS NULL guard alone (20260806150000, never shipped; replaced by 20260806160000) let any RLS-permitted member backdate committed_at through PostgREST by presetting it on a draft and posting, which the Swedish accounting review flagged: committed_at is what the BFL 5 kap timeliness checks and behandlingshistorik treat as the genuine transition time. Preset values now survive posting only for service_role/postgres/supabase_admin; authenticated and anon writers always get the now() stamp. Consequence: the sandbox seed (runs as the requesting user) gets committed_at = posting time, accepted and documented in the route; the demo scripts run as service_role and keep their backdated history. pg tests cover all four paths, with the upper timestamp bound CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): restore superseded migration so the preview tracker stays consistent The preview branch had already applied 20260806150000 when the previous commit deleted the file, orphaning the preview's migration tracker ("Remote migration versions not found in local migrations directory"). Restored with a header explaining it is superseded in the same deploy by 20260806160000, so the unguarded semantics are never live on their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): decide committed_at trust by JWT claims, not current_user The Swedish review found the current_user guard bypassable: commit_journal_entry is SECURITY DEFINER and granted to authenticated, so inside it current_user is the function owner and a member could preset a backdated committed_at on a direct-inserted draft and launder it through the RPC. The guard now reads the JWT claims role (same primitive as the RPC's own tenant guard): preset values survive only for service_role or claim-less backend connections; authenticated and anon callers are always stamped now(), on both the direct UPDATE and the RPC path (new pg test). Both migration files now carry the identical final body so no unguarded intermediate exists as a standalone applyable unit. Behandlingshistorik logging of trusted overrides is follow-up #1444. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |