c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
231 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c0ecf2fa3b |
feat(parties): merge with survivor choice and 30-day undo (#2175)
Phase 1d. merge_parties soft-merges live parties into a survivor (merged_into + archived_at), unions alias keys and copies an org number the survivor lacks; facts, identities and role links stay where they are and readers resolve through canonical_party_id(). undo_party_merge restores the merged rows and the survivor snapshot within 30 days and logs a split decision; a second undo and other companies are refused. pg-real tests cover merge, undo, the window, chained merges and every rejection path. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fc04578818 |
feat(parties): suggestion pipeline from ledger keys and linked documents (#2172)
* feat(parties): phase 1 substrate, one party per counterpart Adds the identity layer above customers and suppliers, which keep their tables and every foreign key and gain a nullable party_id. - parties: company-scoped identity with status (suggested | confirmed), kind, alias keys, origin and merged_into. One live party per org number and company, enforced by a partial unique index; merged losers leave the index so a merge can be undone. This is the unique key the duplicate-invoice guard has lacked, since suppliers never had one. - party_facts: statements with a source, a rank (preferred | normal | deprecated) and two time axes, never overwritten. - party_identities: bankgiro, plusgiro, IBAN and friends per party, with seen and paid counts and a known | unverified status. - party_decisions: every human action on a party as a labelled example. - normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts (strip separators, drop the century on 12 digits, Luhn check, 10 digits). - ensure_party(): find by org number inside the company, else create. Name-only rows never merge at insert time; a name merge is a recorded human decision. - Backfill: one party per existing supplier and customer, merged on org number, suppliers first so both roles land on one party. - Archive contract: the four tables are master data in the full archive. Observed parties (keys derived from voucher and bank text) are not stored; they stay computed by the ledger-context RPC. No posted entry is touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): observed parties from voucher text, ledger_key and its mirror Migrants arrive with vouchers, not bank transactions, so the bank-keyed ledger context is empty for them. This adds the description-keyed twin. - public.ledger_key(text): legibility key on top of the frozen normalize_counterparty_key mirror: strips AP-register prefixes (levfakt, leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier number that follows them, and trailing 1-3 digit runs, never "inköp". Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared fixture list in the pg test. - public.get_observed_parties(company, from_date, limit): posted vouchers grouped by ledger_key(description) with occurrences, variants, expense and revenue SEK from the lines, first/last seen, median cadence and the Laplace-smoothed dominant result account. Excludes storno, opening balance, year-end and VAT settlement, and vouchers that carry a bank merchant name (those stay with get_ledger_deep_context). SECURITY INVOKER, so RLS scopes it. Never stored. - lib/parties/classify.ts: the deterministic pre-classifier moved out of the evaluation script so product and evaluation share one implementation (0.965 agreement with the founder labels, party recall 0.99). - lib/parties/observed.ts: RPC wrapper that classifies each row and derives a display rhythm from the cadence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): tenant-safe composite foreign keys on every party link Facts, identities, decisions, customers.party_id, suppliers.party_id and parties.merged_into now reference parties(id, company_id), so a row can only point at a party in its own company. ON DELETE SET NULL names party_id so role rows keep their company_id. Adds a pg-real test that rejects every cross-company link and checks company_id survives a party delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): suggestion pipeline from ledger keys and linked documents Phase 1c of the parties plan. Fills a migrant's register with suggested parties from what the ledger already knows, never as facts: - get_ledger_key_evidence(company): hard keys per ledger_key from the documents linked to posted vouchers (org number via normalize_org_number, VAT, bankgiro, plusgiro, printed name). Documents whose supplier org is the company's own are the company's sales invoices and only count in self_docs. - apply_party_suggestions(company, user, items): upserts suggestions. Attaches by explicit party_id, org number or an exact alias key; never by name. Identities become known at two sightings. Idempotent. - decide_parties(company, user, ids, kind, note): bulk confirm or dismiss with one party_decisions row each. - parties.suggested_reason: the evidence summary the queue shows per row. - lib/parties/suggest.ts: buildSuggestions (pure) and suggestPartiesForCompany. Keys that mix two org numbers keep neither the hard key nor identities; same-core live parties are reported as similar_to for a person to decide. coreKey() moves into ledger-key.ts. 55 unit tests and 14 pg-real tests pass locally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): decide_parties dismisses suggested parties only Dismiss is the queue's answer to a suggestion; a confirmed party is never archived through it. Superagent P2 on #2172. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(parties): type the rpc mock with its args so the ratchet stays clean Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c0818bb2d2 |
feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)
* feat(sales-orders): kundorder with partial delivery and partial invoicing Adds sales orders (kundorder) as their own non-ledger document between agreement and invoice, for companies that deliver or invoice in parts. Schema (20260902130000): sales_orders + sales_order_items with RLS via user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon execute), company_settings.sales_orders_enabled UI gate, and back-links invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced quantity per order line is DERIVED from the linked invoice lines on non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so no counter can drift and a credited invoice frees its quantity. Header status is draft / confirmed / completed / cancelled; completion is kept by DB triggers from the same derived quantity. Delivery and invoicing progress are derived per line, never stored as status. Service + API: lib/sales-orders (create/update with id-preserving line replace, transitions with compare-and-set, cumulative delivery registration, invoice-from-order through buildInvoiceWriteData so booking stays in the engine, proforma -> order conversion), routes under /api/sales-orders and /api/invoices/[id]/convert-to-order, structured SALES_ORDER_* error codes, archive classification of the new tables. The invoice editor round-trips sales_order_item_id so a draft edit cannot drop the link; GET /api/invoices gains ?sales_order_id=. UI: /sales-orders list, create/edit form reusing the invoice line conventions, detail with deliver and create-invoice dialogs and linked invoices; nav row behind the settings toggle; the webshop row is relabelled webshop_orders; "Skapa order" on proformas. MCP (20260902141000/141001): list/get reads plus four staged writes (create, transition, register delivery, create invoice from order) whose executors call the lib services; op types added to the pending operations CHECK. Tests: route tests for every route (401/400/404/happy), service unit tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts (16 cases, green on staging) covering RLS, numbering guards, the over-invoice trigger incl. release on cancel/credit and cross-company refusal, the quantity floor, and completion maintenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQW7mXvbAPgjUHq7dSEamr * fix(sales-orders): harden kundorder after skeptic and security review Resolves every finding from the PR #2166 review pass in one batch. Order link integrity: replaceInvoiceItems now refuses a line set that drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK), closing the MCP update_invoice header-only edit and the v1 PATCH path that severed the link and freed the quantity for double invoicing. The update_invoice re-fetch, gnubok_get_invoice and the v1 item projection now carry sales_order_item_id so well-behaved clients round-trip it. Quantity math: derived remaining/invoiced quantities are rounded to six decimals and compared with an epsilon (roundQty, qtyGreater) so a float remainder such as 0.5999999999999996 can neither refuse the final partial invoice nor land as an invoice quantity; duplicate explicit picks are summed before validation. Leveransdatum: per-line last_delivery_date (migration 20260902160000); an invoice takes the latest date over the lines it covers and only when the covered quantity was delivered, never the header date and never for an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23). VAT drift: the order stores the customer type and VAT-validation flag its lines were priced under; invoicing refuses with SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the order re-validates the lines. Customer and currency are frozen once invoices exist. Tenant and role gates: composite FK (sales_order_id, company_id) ties a line to its parent's company (Superagent P2); aa_enforce_company_writer_role on both tables so a viewer cannot write through the browser client. Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines instead of dropping those fields. RESTRICT FK errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES. Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with reason), regenerated skills/accounted-api (sales_order_item_id on invoice items), pg tests for the composite FK, the viewer gate and the new columns, unit tests for every changed path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): resolve CodeRabbit round on PR #2166 Quick wins from the review, all in one pass: - replaceInvoiceItems fails closed when the invoice_items snapshot cannot be read (it is both the restore source and the input to the kundorder link guard); the guard branch is explicit in both PATCH routes. - Cumulative delivery registration carries an optimistic predicate on the quantity it read, so two concurrent registrations cannot regress each other; DELETE of an order keeps its allowed status in the predicate and answers a conflict when zero rows match. - Business dates (order date, delivery date, invoice date) default to the Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the delivery date is also the Riksbanken rate anchor. - The invoice-from-order executor treats an event emit failure as non-blocking: the draft already exists. - sales_order_items are archived through their parent with the order currency denormalised, like invoice_items. - Proforma "Skapa order" tolerates a 2xx without a parsable body; the settings toggle refreshes the server-rendered nav. - List route doc states that q matches the order number (customer names are matched client-side). Declined (out of scope for this PR): moving header + line writes and the delivery loop into transactional RPCs (same PostgREST pattern as the invoice PATCH path, tracked as a follow-up), the MCP approval handler's error message shape (pre-existing code outside this change), and the docstring-coverage warning (no repo convention). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling - 20260902160000_sales_orders_hardening.sql collided with main's 20260902160000_parties_substrate.sql after the third sync; renamed to 20260902180000 and made idempotent (DROP ... IF EXISTS before each ADD CONSTRAINT) so a preview branch that applied it under the old version replays it cleanly. Staging's schema_migrations row renamed. - sales_order_items goes back to a direct archive dump: the coverage contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a table with its own company_id; the currency lives on the parent order one file over, joined by sales_order_id. - Scanner ceiling re-baselined after merging main (parties phase 1): 397. - v1 PATCH test queues a real empty invoice_items snapshot now that replaceInvoiceItems fails closed on an unreadable one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): drop the composite FK before its unique index on replay The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped the unique (id, company_id) before the FK that depends on its index, so the preview branch replay (which had applied the file under its former version) failed with SQLSTATE 2BP01. Order swapped; replay verified on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c40e63e3f7 |
fix(parties): skip companies frozen by a migration reset in the party backfill (#2176)
* fix(parties): skip companies frozen by a migration reset in the party backfill Second prod failure of the substrate backfill: suppliers and customers in a company archived by a migration reset are immutable (block_migration_reset_source_mutation), so even setting party_id is refused. Nine suppliers and eleven customers on prod. They are skipped; the archive stays untouched by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(parties): pin why the party backfill skips migration-reset archives A supplier in a company archived by a migration reset cannot take a party_id: block_migration_reset_source_mutation refuses the UPDATE. The backfill skip rule exists because of this trigger. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(pg-upgrade): seed the rows that broke the party backfill on prod A supplier and a customer with an empty name, and a company archived by a migration reset whose rows are immutable. The substrate migration passed the upgrade job and then failed twice on prod for exactly these shapes; any migration that updates every supplier or customer now meets them in CI first. The archived company is guarded on the table existing at the merge-base. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a24982463b |
fix(parties): skip nameless suppliers and customers in the party backfill (#2174)
* fix(parties): skip nameless suppliers and customers in the party backfill The substrate migration failed on prod at the backfill: three rows (one supplier, two customers) have an empty name and ensure_party refuses a nameless party. The file never applied there, so it is corrected in place rather than chased with a migration that could not run before it. Rows without a name keep party_id NULL; the suggestion pipeline names them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): ensure_party writes only under the caller's own identity Authenticated callers must pass their own user id; the service role (auth.uid() NULL: migrations, MCP, cron) may act for another user. Same guard as apply_party_suggestions and decide_parties. Superagent P2 on #2172. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
daeab67dca |
feat(parties): phase 1 substrate, one party per counterpart (#2162)
* feat(parties): phase 1 substrate, one party per counterpart Adds the identity layer above customers and suppliers, which keep their tables and every foreign key and gain a nullable party_id. - parties: company-scoped identity with status (suggested | confirmed), kind, alias keys, origin and merged_into. One live party per org number and company, enforced by a partial unique index; merged losers leave the index so a merge can be undone. This is the unique key the duplicate-invoice guard has lacked, since suppliers never had one. - party_facts: statements with a source, a rank (preferred | normal | deprecated) and two time axes, never overwritten. - party_identities: bankgiro, plusgiro, IBAN and friends per party, with seen and paid counts and a known | unverified status. - party_decisions: every human action on a party as a labelled example. - normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts (strip separators, drop the century on 12 digits, Luhn check, 10 digits). - ensure_party(): find by org number inside the company, else create. Name-only rows never merge at insert time; a name merge is a recorded human decision. - Backfill: one party per existing supplier and customer, merged on org number, suppliers first so both roles land on one party. - Archive contract: the four tables are master data in the full archive. Observed parties (keys derived from voucher and bank text) are not stored; they stay computed by the ledger-context RPC. No posted entry is touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): tenant-safe composite foreign keys on every party link Facts, identities, decisions, customers.party_id, suppliers.party_id and parties.merged_into now reference parties(id, company_id), so a row can only point at a party in its own company. ON DELETE SET NULL names party_id so role rows keep their company_id. Adds a pg-real test that rejects every cross-company link and checks company_id survives a party delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5291806c37 |
feat(parties): observed parties from voucher text, ledger_key and its mirror (#2168)
Migrants arrive with vouchers, not bank transactions, so the bank-keyed ledger context is empty for them. This adds the description-keyed twin. - public.ledger_key(text): legibility key on top of the frozen normalize_counterparty_key mirror: strips AP-register prefixes (levfakt, leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier number that follows them, and trailing 1-3 digit runs, never "inköp". Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared fixture list in the pg test. - public.get_observed_parties(company, from_date, limit): posted vouchers grouped by ledger_key(description) with occurrences, variants, expense and revenue SEK from the lines, first/last seen, median cadence and the Laplace-smoothed dominant result account. Excludes storno, opening balance, year-end and VAT settlement, and vouchers that carry a bank merchant name (those stay with get_ledger_deep_context). SECURITY INVOKER, so RLS scopes it. Never stored. - lib/parties/classify.ts: the deterministic pre-classifier moved out of the evaluation script so product and evaluation share one implementation (0.965 agreement with the founder labels, party recall 0.99). - lib/parties/observed.ts: RPC wrapper that classifies each row and derives a display rhythm from the cadence. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f1230282a9 |
feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings A company running several bank accounts (main bank on A, company card on M, both imported via CSV) could not route each account's bookings into its own series: every bank_transaction booking took the single company-wide default from default_voucher_series_per_source_type. - cash_accounts.voucher_series (nullable, single letter): per-account override, editable under Inställningar → Bokföring → Verifikationsserier per bankkonto (new PATCH /api/cash-accounts/[id]). - resolveCashAccountVoucherSeries(): step 2 of the resolution order (explicit pick → account override → per-type map → A). Wired into the book route and createTransactionJournalEntry, which covers categorize, the agent, pending operations and the v1 API. - Booking dialog gets the series picker, seeded from the server via /voucher-sequences/next?source_type&cash_account_id so dialog and route can never disagree. An unresolved embedded picker omits voucher_series so a stray 'A' never overrides the account's series. Scope: bank_transaction bookings only. Invoice settlements matched from the bank keep their payment series; bulk-book resolves inside its RPC (see DECISIONS.md). Migration applied to staging as 20260902121420. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh * fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish compliance review): - Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series is a behandlingsregel that outranks the audited per-type map. New trigger audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in the report, pg-real test. Applied to staging as 20260902124513. - Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a verifikationsserier_regler block with the resolution order and the two exceptions (invoice settlements, samlingsverifikat); the per-account mapping itself is in data/cash_accounts.json. - Settings picker uses the same closed list as the manual verifikat form (presets plus letters already in use) instead of all 26 letters; strings moved to messages/sv.json and messages/en.json. - /voucher-sequences/next applies the account override only for source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot show a series the entry will not get. - Book route resolves the series from the account the row ends up on after a stranded-row repoint, not the stale one. - PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a Postgres cast 500; the series lookup logs a warning when it fails open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
61a76b1669 |
feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw (#2157)
* feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw Phase 0 of the Kontakter plan: make the counterparty resolver measurable before building it. - Migration 20260902120000 enables pg_trgm (trigram blocking of counterparty keys) and drops the two context-graph tables from 20260706193007 whose feature code was never merged and which prod no longer has, so fresh replays agree with prod. - tests/pg/parties-phase0.pg.test.ts pins the extension, a sanity check on trigram ranking, and the absence of the graph tables. - scripts/parties/draw-golden-set.sql is the reproducible, read-only draw of the 200-key labelling sample (three strata, md5-ordered) and the payee-identity base rate. The drawn rows contain customer voucher text and are kept in gitignored dev_docs, never in this public repo. - scripts/parties/README.md records the label vocabulary and the numbers measured on prod on 2026-09-02. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(archive): drop the two context-graph tables from the archive contract The migration in this PR removes graph_counterparties and graph_transaction_counterparties, so the full-archive contract must stop classifying them: tests/schema/no-phantom-columns.test.ts asserts that every classified table exists in the migration replay, and the live-DB twin in tests/pg/full-archive-coverage.pg.test.ts asserts the same against information_schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f266c386f3 |
chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
18cbc4c30a |
fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)
* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables Security audit 2026-09-01, critical items. - api_keys INSERT requires user_id = auth.uid() again (an admin could forge a key for any co-member and act as them in every company they belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the identity and credential columns against user-session UPDATEs. - rotate_mcp_refresh_token and validate_and_increment_api_key become service_role only: they match rows by a presented SHA-256, so a hash readable by co-members was a bearer credential. - validate_and_increment_api_key fails closed when the key's user is no longer a member of the key's company. - provider_consent_tokens and provider_otc: the DELETE policies collapsed to "caller has any team row" (correlated subquery on a non-existent team_members.company_id). All member policies dropped; service_role only, matching every existing code path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): role gates, ownership guards and posting integrity in the database Security audit 2026-09-01, high items at the database layer. - One table-level guard, enforce_company_writer_role(), blocks the read-only viewer role on 55 company-scoped tables including through the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role claim so it fires inside definer bodies; no-op for service_role and trigger cascades. - company_members user_id/company_id immutable from user sessions; invitations can never grant owner; team_members gains a transition guard (admins keep non-owner role moves); companies team_id and archiving are owner-only and team attachment needs team membership. - Direct statements (current_user = authenticated) can no longer insert posted headers, add lines under posted verifikat, or post a draft with a voucher number the sequence never issued. Sanctioned RPCs run as the definer and are untouched; the engine's own draft-then-post shapes still pass. - create_document_version refuses viewers and foreign storage paths; validate_version_chain needs membership and loses anon EXECUTE; match_documents / match_booking_templates lose anon; cron maintenance RPCs become service_role only; the production-only seed_asset_categories is dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * build: pin tsx as an exact devDependency instead of fetching it with npx at build time prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker and CI build downloaded tsx@latest and its transitive tree from the registry with no integrity check, inside the build environment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): refuse the viewer role on API-key and MCP write paths The v1 wrapper and the MCP company routing checked company membership but never role, and both run as service role, so a read-only viewer holding an API key could post vouchers and change settings through the API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY for viewers on v1; MCP write tools refuse viewers the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin Uploads persisted the browser-declared mime type and the inline proxy served it verbatim, sandboxing only text/html; the storage proxy forwarded the uploader's Content-Type. Any writer, or any Peppol sender, could plant a scripted SVG or XHTML that executed on app.gnubok.se. - inline route: allow-list of natively safe types (PDF, raster images) served as before; everything else gets the opaque sandbox CSP. - storage proxy: octet-stream + attachment + sandbox unless the DB mime for the key is on the allow-list. - document-service: the stored mime is the magic-byte validated type. - logo upload: magic-byte validation, SVG refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG Same pattern as the company logo route: the logos bucket is public, so a scripted SVG (or anything declared as an image) must never land there. The upload pickers stop advertising SVG. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user The callbacks resolved the pending row by oauth_state alone, so a victim who completed an attacker-initiated consent had their bank account, merchant account or store attached to the attacker's company. requireFlowInitiator() now requires the cookie session of the user who started the flow: no session redirects to login with the callback URL preserved, a different user is refused and nothing is exchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter WooCommerce and Shopify syncs fetched a member-editable store URL with plain fetch() and redirect following under the service role, and the invoice PDF renderer fetched company_settings.logo_url unguarded. All three go through a new safeFetch() (public-IP validation via url-guard, https only, redirect: 'manual', body size cap) and re-normalise the stored host at use time. checkRateLimit() keeps failing open on hosted but logs one error per process when Upstash is not configured and exports isRateLimiterConfigured(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie getAuthenticatorAssuranceLevel() without arguments derives nextLevel from session.user.factors, which comes from the unsigned sb-*-auth-token cookie. Deleting factors from the cookie made an enrolled account look like it had nothing to step up to, on every /api route and in requireAuth. Both gates now read factors from the getUser() result or listFactors() and the level from the verified JWT claim, and fail closed on errors. Page-branch gate hardened the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user The arcim-migration callback exchanged the provider code onto whatever consent the one-time state named, with no check of who completed the flow and no org-number comparison, so a phished Fortnox admin handed their ledger to the attacker's company. provider_otc now records the initiating user (migration 20260902100000); the callback requires that session and, after the exchange, refuses a provider company whose org number differs from the consent's company. The Gmail and Skatteverket callbacks enforce the same initiator check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): BankID signup confirms the email before linking the identity Signup created an email-confirmed, MFA-exempt account for any address the caller typed and returned a magic link, so an attacker could pre-register a victim's email and keep a permanent BankID login into the account the victim later adopted. The user is now created unconfirmed, the identity carries email_verified_at NULL (migration 20260902101000), bankid_linked is not set until the mailed confirmation is clicked, and BankID login of a pending identity is refused with the confirmation re-sent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes A user-registered redirect URI was allowlisted globally, the consent page named no client, and all scopes were pre-checked, so one phishing link handed an attacker a full-scope key for the victim's company. Registered URIs now resolve only for the registrant or a colleague sharing a company; the consent page shows the client identity and redirect host; non-built-in clients default to read-only pre-checks; scopes are capped by the user's role (viewer: read only) at consent and at /token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log - register client handles the new confirmation_sent response from BankID signup with the existing inbox screen instead of calling verifyOtp. - BankID login surfaces the email_unconfirmed explanation. - WooCommerce settings map woocommerce_error=wrong_user to its own copy. - Logo help text no longer advertises SVG. - DECISIONS.md records the audit remediation choices. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down by the one legacy error the change removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
867767a22f |
feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) (#2148)
* feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests. Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so <local>+anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en). Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
191fb2cfce |
fix(audit): alias learning is not a rule change, stop logging it (BFNAR noise) (#2134)
Production falsified 20260901103000's exclusion list within 30 minutes of deploy: 15 of the first 16 UPDATE audit rows on categorization_templates changed only the learning columns plus counterparty_aliases, because the learning path (lib/bookkeeping/counterparty-templates.ts) merges new aliases in the same write that bumps occurrence_count. Projected ~800 noise rows/day against ~50/day of real rule changes, each one rendered into the legally-facing behandlingshistorik as "Konteringsmall aendrad: Alias". counterparty_aliases joins the trigger's strip list. The trade-off is explicit: a human editing ONLY aliases is no longer logged. Accepted because alias growth is overwhelmingly automatic, and a change that also touches accounts, VAT, pattern or the active flag still logs: the first real such row (2026-09-01 19:02:17Z, debit/credit/vat accounts changed by the learning loop, BFN's automatkontering case exactly) was captured correctly and stays captured under the new WHEN clause. The pre-fix noise rows stay in audit_log (append-only). The read model stops labelling the column, so alias-only diffs, historical ones included, render as no-ops rather than rule changes; a diff that also carries a real change shows only the real change. pg-test extended: alias+learning update writes no audit row, alias+account update still does. Read-model test pins the pre-fix noise row shape to null. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a08bf51ced |
feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR 2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record "forandringar i bokforingssystemet som paverkar bokforingsposternas behandling samt nar dessa forandringar infordes", and BFN's commentary names behandlingsregler (automatkonteringar, fasta procentsatser) and new program versions as the examples. Until now both changed without a trace. Audit triggers on the behandlingsregler tables and the import logs: mapping_rules, booking_template_library, categorization_templates, salary_payroll_config, sie_imports, bank_file_imports. categorization_templates learns on every booking (occurrence_count, confidence, last_seen_date), so those telemetry-only updates are excluded by a WHEN clause the same way the api_keys request counters are (20260721115701): only real rule changes are logged. Measured against prod that is roughly 3 800 new audit rows a month against an audit_log already taking 371 688, so about +1 %. app_releases is an append-only log of program versions seen in production, written by the runtime the first time a build answers a request. Vercel exposes no build hook we can trust to write the row, so /api/version records it inside after(): the handler returns synchronously and a floating promise could be frozen before the insert lands, which is how a version log ends up silently empty. The service client is constructed lazily so the constantly polled public probe pays nothing once the module guard is set. Program versions are rolled up per Swedish calendar day in the report. main takes ~570 merges a month, so one event per version would be on the order of 7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the ~400 events a real company's year contains. The statutory unit is the date, and the same sentence qualifies the requirement to changes that affect processing, which a deploy list cannot distinguish anyway. app_releases keeps the per-version truth for anyone who needs to go deeper. AuditLogEntry.user_id becomes string | null. The column is nullable and write_audit_log() falls back to auth.uid(), which is NULL for a service-role or global write; the company-less salary_payroll_config rows are the first that routinely hit it, and the read model already coded for it. Also restores the point citations the 2026-07-27 pass removed while the chapter was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified against BFN's consolidated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): fix two fixture bugs in the behandlingshistorik trigger tests pg-real caught both, and neither is in the migration: the inserts fail before the trigger is reached. mapping_rules.rule_type is constrained to mcc_code / merchant_name / description_pattern / amount_threshold / combined; the test used 'merchant'. booking_template_library's btl_insert policy requires current_user_can_write() and company_id = current_active_company_id(), so the authenticated insert needs a company_members row and a user_preferences.active_company_id, the same setup booking-template-hidden.pg.test.ts uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): assert the booking-template audit row inside the user transaction withUserContext always rolls back, so the audit row the trigger writes is gone before an outside connection can see it. The trigger fires in the same transaction as the write, so the assertion belongs there too. The other cases in this file write on the pool (autocommit) and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * fix(reports): name every build id in the per-day program-version entry Raised by the compliance review on #2097: the roll-up listed five ids and a count, which leaves an auditor unable to reconstruct which versions ran that day. app_releases keeps the full record, but the report is the surface anyone actually reads. A day is bounded by the deploy rate (~19), so the full list stays one readable cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0406e628e1 |
fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them
The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").
Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): gate issuance on the seller VAT number (skeptic finding)
The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).
Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.
Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.
The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths
Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.
Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* test(email): refresh brand-mail snapshots for the coherent VAT fixture
The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
237bdd0366 |
perf(db): index the foreign keys that actually carry delete fan-out, and event_log by company (#2115)
Two of 311 performance advisories have a real mechanism. event_log (265k rows) has no index containing company_id, so a company-scoped read falls back to a primary-key scan with a filter. And journal_entries has 37 inbound foreign keys with roughly 16 children lacking a usable index, so every deleted entry fires a sequential scan per child: stripe_payouts has 0 rows and 77 445 seq scans, supplier_invoices 2 153 rows and 725 816. This is index hygiene, not a user-facing bug. In the measured 24 hours there were zero 5xx across 250 838 gateway requests at p95 63 ms, and GET /api/events had no traffic at all. Roughly 14 indexes, not 173. Child tables under about 500 rows are skipped: a one-page sequential scan beats an index probe and the planner ignores the index anyway. Every candidate was checked against prod first, and any already covered by an existing index whose partial predicate is implied was dropped, so this adds no duplicate. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77becf3d65 |
fix(documents): record archive integrity checks in their own ledger so the nightly control advances again (#2108)
The 03:00 WORM verification cron stamped last_integrity_check_at on document_attachments. enforce_period_lock_documents() fires on any UPDATE of a row whose journal entry sits in a closed or locked period, without checking whether the entry link actually changed, so a read-only integrity stamp was rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the rejected rows re-sorted to the head every night and the batch became permanently 200/200 blocked. Both call sites discarded the update error, so nothing logged and nothing alerted. Prod state: 34 557 current-version documents, 24 083 never checked, last successful stamp 2026-08-31 03:00, nightly successes already decayed to single digits. Migration 017's enforcement triggers are legally required and never-touch, so this does not narrow the trigger. The verification outcome moves to its own document_integrity_checks table and the cron stops writing document_attachments altogether, which takes the trigger off the write path. The legacy column stays in place. Failures are now counted, logged and reported in the route's summary: the silence is why this went unnoticed for weeks. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2c46d69d21 |
fix(sandbox): complete the teardown for the four FK and guard blockers added since August (#2112)
cleanup_sandbox_user deletes supplier_invoices and journal_entries without first clearing four blockers added or missed since it was last fixed: supplier_payment_batch_items (RESTRICT FK, that table was created 2026-08-10), fiscal_periods.opening_balance_entry_id (NO ACTION FK whose NULLing is itself blocked by enforce_opening_balance_immutability), terminal webhook_deliveries rows, and payment_match_log rows whose company_id IS NULL, which fail the shared audit bypass because it requires a non-null company. Prod: 9 stale sandbox users, oldest 2026-07-22, retried nightly and failing forever, so anonymous demo tenants accumulate indefinitely. The bypass stays scoped to sandbox teardown. The audit-log immutability and the webhook terminal-delete guard are not weakened for normal tenants, and the pg test asserts they still bite for a non-sandbox tenant. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
088754d61a |
fix(processing-history): register the missing event types, and strip the PII two of them carry (#2111)
* fix(processing-history): register the missing event types, and strip the PII two of them carry Ten event types are emitted by code but absent from processing_event_types, so every append fails the foreign key. Appends are best-effort try/catch, so no user request fails, but the internal audit trail is empty for ten kinds of legally motivated act, including the BFL 5 kap 5 § rattelse record when a user swaps a transaction's underlag (TransactionDocumentReplaced) and the SOC 2 revocation record (OAuthClientRevoked). Order matters and is deliberate. Two invoice-inbox events, RateLimitedDropped and AttachmentsTruncated, put the raw sender address and mail subject in their payload. Registering those types first would start persisting that PII into an append-only table whose UPDATE is trigger-blocked and which the archive's erasure path excludes. The strip therefore ships in this same commit, ahead of the migration. Only the invoice-inbox emitter was edited. whatsapp-inbox shares the RateLimitedDropped type name with a payload that carries no phone number. Closes the class rather than the two logged instances: a TypeScript union makes an unregistered literal a compile error, and the pg test asserts the database catalog is a superset of the code's list, generated from the union. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc * fix(processing-history): strip the inbound-mail PII in the database, not by deploy ordering Review finding (superagent-security, P2): shipping the emitter fix and the catalog migration in one commit is not the same as one instant. Migrations apply on merge while the replacement build takes minutes, so an old instance can still write a sender address and mail subject in that window, and such a row is permanent: processing_history takes no UPDATE and no DELETE, and the archive export excludes it from the erasure path. Adds a BEFORE INSERT trigger stripping `from` and `subject` from the RateLimitedDropped and AttachmentsTruncated payloads, and keeps it afterwards so the invariant belongs to the table rather than to one emitter's good behaviour. The jsonb object check is load bearing: `payload - 'key'` raises on a jsonb array and payload's shape is not constrained. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
57d757192e |
fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs (#2106)
* fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs
Supabase's bootstrap ALTER DEFAULT PRIVILEGES grants EXECUTE on every
public-schema function to PUBLIC, anon, authenticated and service_role, and
PostgREST publishes each one at /rest/v1/rpc/<name>. Seven SECURITY DEFINER
functions were therefore unauthenticated cross-tenant primitives that bypass
RLS for anyone holding the public anon key:
sync_team_to_company INSERTs into company_members, the table
user_company_ids() and every RLS policy read
claim_due_webhook_deliveries returns every tenant's webhook payloads
generate_delivery_note_number UPDATEs company_settings, needs only a company id
generate_article_number UPDATEs company_settings and articles
generate_invoice_number UPDATEs company_settings and invoices
peek_next_invoice_number leaks another tenant's prefix and next number
get_next_arrival_number leaks another tenant's ankomstnummer series
The last three carried a guard, and it did not hold. Its shape is
"IF auth.uid() IS NOT NULL AND NOT EXISTS (membership) THEN RAISE", with a
comment explaining that a NULL auth.uid() means service role or cron and is
trusted. The anon key's JWT carries no sub claim, so auth.uid() is NULL for
role anon as well, and the guard short-circuits straight into the trusted
branch.
Revokes EXECUTE from PUBLIC and anon on all seven, and from authenticated on
the two with no user-session caller. PUBLIC is mandatory: proacl carries
"=X/postgres", so revoking from anon alone leaves has_function_privilege true.
The five numbering RPCs the app calls on the user's session client are
re-granted to authenticated only after their guard was replaced with a
fail-closed one. A sweep then revokes PUBLIC and anon from every remaining
definer writer in public; all 20 carry explicit authenticated and service_role
grants, verified against prod, so no signed-in or service path changes.
tests/pg/definer-function-grants.pg.test.ts generates its assertion from that
same sweep rather than a hand list, because hand-listing is exactly how the
three guarded numbering RPCs came to be declared safe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
* fix(security): keep procedures out of the definer-writer sweep
Review finding (CodeRabbit, Major): the sweep predicates excluded only trigger
return types, so a SECURITY DEFINER PROCEDURE would match and the migration
would then run REVOKE ... ON FUNCTION against it, which Postgres rejects,
aborting the whole migration. The pg test's offender query had the same gap and
would have reported a procedure the migration could not fix.
public holds no procedures today (verified against prod 2026-09-01), so this is
a guard against the first one anyone adds rather than a live bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aabddb592f |
feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
36123cef23 |
feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget (#1751)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(connect): update ledger pg test to re-versioned migration 20260831200000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): redact opaque path segments before usage metering; correct stale RPC-source comment GET/DELETE /sessions/{id} and /accounts/{uid}/... carry the raw EB session id / account uid in the pathname; metering persisted it in cleartext next to the ledger that stores only sha256(handle). Opaque segments (UUID, long hex, long base64url) now become ':id' before the connector_usage_events insert. Migration comment now cites the real prior RPC source (20260831190000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): percent-encoded path segments count as opaque in metering redaction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): PR #1751 review batch: https-only EB URL, body-covering timeout, quota reservation, delete-after-success, doc fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): bind the /sessions code exchange to its verified pending state; ceiling +1 Verified state signature, key/service match, and an existing pending row now precede the EB exchange; a concurrently consumed state closes the just-minted upstream session and 409s. no-phantom-columns ceiling 391 for countHeldConnections' computed .or() timestamp filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com> |
||
|
|
0ff1b05553 |
feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly (#1748)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(connect): update pg test to re-versioned migration 20260831190000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): RPC errors answer 503 not 401; X-Connector-Key wins over Authorization A hosted DB error mapped to 401 made the instance sync treat a pooler blip as key revocation and delete its entire connector grant cache, zeroing the 72h offline grace. 503 lands in the sync's keep-grants branch (already test-pinned). Bearer-first extraction hashed the upstream token on dual-header proxied calls, 401ing the exact shape X-Connector-Key exists for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): sync deletes grants only on a body-proven connector rejection, never bare 401/403 A WAF challenge page, edge deployment protection, or an egress proxy answers 401/403 without the hosted app ever running; trusting status alone wiped the instance's 72h offline grant cache within the hour. Deletion now requires the hosted route's own rejection code in the JSON body; codeless 401/403 keeps grants (server_error branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): PR #1748 review batch: https-only connect URL, atomic pin, prefix-gated Bearer, deferred metering, entitlements validation, integer months - GNUBOK_CONNECT_URL must be https (http only for loopback); invalid or plaintext URLs disable the connector instead of sending the key. - instance_url pin update filters on IS NULL; a lost race re-reads and reports the winner's pin. - extractConnectorKey: a Bearer is the connector credential only with the gnubok_ck_ prefix; upstream Bearer falls through to X-Connector-Key. - Usage metering runs via after() off the response path (inline outside a request scope). - Sync validates entitlements shape: unknown status or malformed current_period_end keeps grants (server_error), never deletes. - issue-connector-key rejects fractional --months. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com> |
||
|
|
9fe37b85b5 |
feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended An API key gets an optional ceiling in SEK. Above it the agent may still stage the work, it just may not finish it alone: a human approves the same verifikat in the app. Default is NULL, so every existing key keeps its behaviour and turning this on is entirely opt-in. Enforced at the two places an API key reaches the ledger, and at both the refusal happens BEFORE the point of no return: - MCP: in commitPendingOperation, before the atomic claim, so the operation stays 'pending'. Behind the claim it would be caught by the generic handler, marked terminal 'rejected', and the staged verifikat would be gone. - REST: in journal-entries.commit, before commitEntry, so the draft stays a draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run refuses too, rather than promising a voucher number the key cannot deliver. Not enforced inside commit_journal_entry: a RAISE there is swallowed by engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function that issues every voucher number. Operations whose amount is only known during dispatch (batch allocation, bulk booking, the settlement link paths) fail OPEN behind an explicit allowlist. Pricing them ahead of dispatch would be a guess, and a wrong guess silently breaks batch allocation the day someone sets a limit. The allowlist is derived from what production actually stores: create_voucher carries total_debit on 1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003, create_supplier_invoice_from_inbox carries total on 208 of 228. This is a blast-radius cap, not a security boundary. A per-entry ceiling is defeated by splitting one entry into several, and an LLM will find that, so UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the primitive that actually bounds exposure and is left to a separate change. The guard is written NULL-first everywhere. An absent, unparseable or non-positive ceiling always means unlimited, never "block everything". Agents read their own ceiling from gnubok_get_agent_briefing instead of discovering it by burning a staged verifikat on a 403. Changing a ceiling is auditable: it now renders in behandlingshistorik (BFL 5 kap. 11 §). The audit trigger already fired on the column, but the report dropped the event because the field was not in its diff map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(skill): regenerate accounted-api skill for the new commit pitfall apiskill:check is a ratchet: the generated reference must match the endpoint registry. Never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(agents): pin the DB default itself, and declare the briefing field required Two review findings, both real: - the default test stored an explicit NULL, so it stayed green even if the column default changed to a positive ceiling: the one change that would silently start blocking every existing key. It now omits the column. - gnubok_get_agent_briefing documents unattended_commit_limit as always present and emits it unconditionally, so it belongs in the output schema's required list. Declined the NOT VALID constraint suggestion, with the reason recorded in the migration: api_keys is 388 rows / 768 kB in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): name the TOCTOU window in the REST ceiling check A security scan flagged that the line sum is read before commitEntry, so a concurrent write to the draft's lines can post over the ceiling. Real, and accepted: closing it means enforcing inside commit_journal_entry, where a RAISE becomes a retryable 500 and destroys the staged operation on the MCP path. Recorded in the code rather than left implicit, so nobody later mistakes this for a hard control. A per-entry ceiling is already defeated by splitting, which needs no race; the cumulative rolling-window limit is the primitive that bounds exposure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): price the settlement and batch paths that were bypassing the ceiling A security scan flagged that known money-posting operations fail open, and it was right. The first cut priced only create_voucher, categorize_transaction and create_supplier_invoice_from_inbox, on the belief that the batch and settlement paths computed their totals only inside SQL at dispatch. Production says otherwise: the staged preview already carries the amount, because it is the number a human is shown when approving the operation. Over the last 120 days each of these is present and numeric on 100% of that type's staged rows: link_transaction_journal_entry transaction_amount 1369 rows bulk_book_transactions tx_sum 273 rows link_supplier_invoice_voucher payment_amount 55 rows match_batch_allocate total_allocated 24 rows mark_invoice_paid total 3 rows So a key with a ceiling could post any amount through the four largest settlement paths. Now priced, and the ceiling applies. Only reconciliation_match stays unpriced: it carries pair_count, which is a COUNT. Pricing off that would compare pairs against kronor, which is worse than not enforcing. link_document_to_voucher and attach_document_to_transaction move no money at all; the transaction_amount they carry is context, not a posting. Genuinely unpriceable types still fail OPEN. This control can only ever narrow what a key does, and a wrong guess at an amount blocks a legitimate commit, so guessing high would leave an agent unable to work. Adds a test that walks the whole allowlist, so a typo'd field name cannot silently make a type unpriceable again: that is exactly the hole this closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room The tools/list context-budget bench sits at 65 000 tokens and main now leaves roughly 20 tokens of headroom. An always-present field on the briefing's output schema costs about 85, so this addition alone pushed the bench red. The bench's own note is explicit that the answer is to demote a tool rather than raise the ceiling, so raising it here would be the wrong trade for a nice-to-have. Nothing is lost that matters: the operation is never destroyed when it is refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs one round trip and no work. That error already carries both attempted and limit, and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing is worth doing once there is budget to spend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(api): spell affärshändelse correctly in the commit pitfall Fixed in the route's registerEndpoint pitfalls, which is the source; the skill reference is regenerated from it and never hand-edited. 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> |
||
|
|
2814d70cb4 |
feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076)
* feat(bookkeeping): cascade opening-balance corrections to later years Fortnox/SIE migrations book one IB verifikat per imported year, so correcting one year's ingaende balans left every later year's linked IB carrying the stale figures (support case: a 2019 IB fixed in Fortnox after export never reached Accounted, skewing all subsequent saldon). - POST /api/import/opening-balance/correct accepts cascade: true and applies the correction's per-account delta to each subsequent year's IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts). Locked/closed/lock-dated/bokslut years are skipped and reported, never forced; a failed year is compensated and the cascade continues. - CorrectOpeningBalanceDialog offers the cascade as a default-checked checkbox when later years have their own IB verifikat, and when the current year is blocked it points at the earliest open year's IB verifikat instead of dead-ending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): atomic cascade replacement + review findings for PR #2076 - Cascade now books each later year through replaceOpeningBalanceEntry (one RPC transaction: storno + corrected voucher + pointer swap, CAS on the expected old entry), removing the create/reverse/relink window that could leave a period linked to a reversed IB entry. - Cascaded verifikat keep the original lines verbatim (descriptions and dimensions) and append labelled IB-rättelse adjustment lines per changed account instead of collapsing per-account nets. - Year-end lookup fails closed: a query error skips the period instead of reading as 'no bokslut'. - Dialog always sends the cascade flag (a cold reference cache no longer silently disables the default-on cascade), the success toast separates blocked years from failed years needing review, and the checkbox notes that a resultat correction may still need an omforing to 2091. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * feat(bookkeeping): Fortnox-style inline IB correction without storno Founder decision 2026-08-31: IB edits in open unlocked years should feel like Fortnox (change the number, no extra verifikat) instead of always producing a storno + rebook pair in serie A. - Migration 20260831150000 redefines correct_entry_lines_inline to admit source_type 'opening_balance' with three IB guards: only the period's current linked IB, no posted bokslut on the period, and replacement lines restricted to balance-sheet accounts (class 1-2). The entry id never changes, so fiscal_periods.opening_balance_entry_id stays valid and every report reads the corrected lines automatically. Storno, year_end and vat_settlement stay excluded; locked/closed/lock-dated periods are still refused (BFL 5 kap 5 par: storno is the only track there). - New POST /api/import/opening-balance/correct-inline: diff-based strike and replace inside the same IB verifikat, same OB_* pre-flight codes as the storno route, RPC rule violations surfaced verbatim as 409 OB_INLINE_REFUSED. With cascade: true the per-account delta is appended as labelled IB-rattelse lines inside each later open year's own IB verifikat (cascade mode 'inline'): a multi-year correction with zero new verifikat. - CorrectOpeningBalanceDialog computes the row diff (untouched lines keep ids, descriptions and dimensions) and posts to the inline route; copy updated (no storno language), toast reports inline updates. - In-app agent guidance (shared-rules) updated to describe the inline flow and the cascade checkbox. - Tests: pg-real suite for the redefined RPC (IB accept, linked-IB guard, bokslut guard, P&L guard, structural types still refused, non-IB unaffected), route tests, cascade inline-mode unit tests. The storno-based /correct route and engine paths are untouched: they remain for the import replace flow and API compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance The verifikation-draft period-lock gate test uses the literal 'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB bullet in shared-rules carried the same string in every prompt and broke the open-period assertion. Reference Bokföringslagen generically instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): derive inline cascade delta from the rattelse log Swedish-review finding on PR #2076: the cascade delta was computed from a route-side line snapshot read before the RPC, which a concurrent edit could theoretically desync from what the RPC actually committed. The delta now comes from the RPC's own journal_entry_rattelse_log row (struck_lines/added_lines snapshotted inside the RPC transaction), so the cascade always matches the committed base correction. Also softened the blocked-year guidance copy (declared-status is an assumption, not a verified fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): visible cascade failure + dimensions-aware no-op check CodeRabbit round-2 findings on PR #2076: - A cascade that failed to run (log fetch error, unexpected throw) was returned as an empty successful summary, so the dialog reported nothing wrong while later years stayed unverified. Both routes now mark it failed: true and the dialog tells the user to check later years' opening balances. - The RPC's no-op guard compared account/amount/description only, so a dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The comparison keys now include canonical dimensions jsonb text (fixed in the unmerged 20260831150000 migration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dc07ca8872 |
feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
|
||
|
|
f43a6653f1 |
feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date payment_date drives the booking entry date but was only editable via the v1 PATCH. Close the gap on both remaining surfaces: - New staged MCP write tool gnubok_update_salary_run (search-only catalog; tools/list budget is at zero headroom) accepting the exact v1 PATCH field set: payment_date, voucher_series, notes. Draft-only with the same optimistic lock semantics, via a new shared service lib/salary/update-run.ts used by both the staging preflight and the commit executor. - Run header UI: payment date on a draft run is now an inline date input (prefilled, committed on blur/Enter, snaps back on failure), saved through the existing internal PATCH. Read-only once not draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): op-type migration, calc invalidation on date change, scanner compliance Consolidated CI + review fix pass for #2041: - pg-real: add 'update_salary_run' to pending_operations_operation_type_check (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1). - Swedish accounting review: a payment_date change on a draft run now clears every roster row's calculation_breakdown (shared service and internal PATCH alike), so both book preflights refuse the run until a recalculation has run against the new date; skatteavdrag and the AGI redovisningsperiod follow the payment month. Staging preview exposes invalidates_calculation and the next hint states the clearing. - no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with a documented reason for the inherent patch-shaped UPDATE payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): close skeptic findings on payment_date editing Skeptic round 1 refuted two paths; both closed: - Retry idempotency (correctness): the calculation_breakdown clear was gated on new-date-differs-from-stored, so a retry after a partial failure (header committed, clear failed) compared against the already updated date and skipped the clear forever, leaving a stale calculation bookable. The clear is now gated on payment_date being SUPPLIED, on all three surfaces (shared service, internal PATCH, v1 PATCH: the v1 route previously had no clear at all and bypassed the invariant). - Kontantprincipen (compliance): AGI derives its redovisningsperiod from period_year/period_month while the verifikat books on payment_date, so a cross-month payment_date change could book salary in one month and declare it in another. All three edit surfaces now refuse a payment_date outside the run's period month with the new structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date input is min/max-bounded to the period month. - The internal PATCH update is now optimistic-locked on status='draft' (races return 400 instead of silently writing), matching the v1 PATCH and the shared service, and the clear cannot fire for a run that left draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): carry book_skattekonto op types through the constraint re-create The sibling migration 20260830130000 (merged from main) re-created pending_operations_operation_type_check with book_skattekonto_row and book_skattekonto_rows. This branch's 20260830150000 sorts after it and re-creates the constraint wholesale, so its list must be that migration's superset or the two values would be silently revoked at apply time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): value-validate internal PATCH and grandfather out-of-period dates Two skeptic follow-ups: - The internal PATCH now validates values, not just keys: JSON body must be an object, payment_date must be ISO (shared ISO_DATE_RE), voucher_series a single A-Z letter, notes a string of max 2000 chars or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing unvalidated can reach the DB through the whitelist. - Creation does not (yet) couple payment_date to the period month, so a legally created out-of-period date must stay correctable. All three edit surfaces now allow day adjustments within the run's CURRENT payment month as well as the period month (grandfather clause); no move can introduce a new wrong month. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): resolve migration version collision with delete_draft_invoice Main's delete_draft_invoice PR landed on the same 20260830150000/150001 versions and also re-creates pending_operations_operation_type_check. Rename this branch's pair to 20260830160000/160001 (applies last) and carry delete_draft_invoice through the wholesale re-create so nothing is silently revoked. Final list = sibling's list + update_salary_run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * docs(salary): regenerate accounted-api skill for the new PATCH pitfalls apiskill:check byte-compares the generated skill against the registry; the two pitfalls added to the v1 salary-runs PATCH endpoint made references/salary-runs.md stale and failed Core Build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7162053e89 |
fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual (#2037)
* fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual A foreign-currency invoice whose receivable (1510) or payable (2440) was booked in plain SEK could not be settled by any API path: link_invoice_to_voucher and link_supplier_invoice_to_voucher failed closed with LINK_VOUCHER_CURRENCY_MISMATCH on every SEK-booked matched-side line. Port match_batch_allocate's cross-currency settlement into both RPCs, with identical sign conventions: when every matched-side line is genuinely SEK-booked, the invoice has a sane exchange_rate, and the voucher's SEK sum is within 10 percent of remaining * rate, the voucher settles the full remaining and the FX residual (booked_sek - settled_sek) is booked to 7960 (loss) / 3960 (gain). Because the linked voucher is posted and immutable, the residual lives in its own balanced two-line verifikat committed through commit_journal_entry, dated on the voucher's entry_date with an explicit open-period check. Every ambiguous case (mixed readable/SEK lines, third currency label, missing rate, kontantmetoden, deviation outside the band, locked period) keeps the existing mismatch codes, now with details.reason. Verified with a 14-scenario transactional probe against staging Postgres (rolled back; catalog untouched) plus tests/pg/link-voucher-fx-residual .pg.test.ts, which applies the migration inside each test's transaction so it runs against a database that has not applied it yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): move FX residual migration past 20260830101500 from main Main gained 20260830101500_seed_agent_atom_bodies.sql, a later version than this branch's 20260830100000; renamed to 20260830120000 so the migration chain stays ordered. Test and decision-log references updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): gate the FX fallback on readable line count, not sum Skeptic counterexample: a matched-side line labelled with the invoice's currency but carrying amount_in_currency = 0 is a readable LINE that sums to 0. The sum-based gate engaged the fallback while the line's real SEK ledger movement was excluded from the settled sum, over-crediting the receivable (mirrored on AP) and booking a phantom FX result. The gate now counts readable lines: any readable line disables the fallback, so the SEK sum is provably the full matched-side ledger amount whenever it engages. Verified against staging Postgres in a rolled-back transaction: both counterexample vouchers now refuse with LINK_*_CURRENCY_MISMATCH and no writes, while the plain-SEK settlement paths still book balanced 7960/3960 residuals. Regression tests added for both sides. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): move FX residual migration past colliding 20260830120000 from main Main gained 20260830120000_reminder_text_overrides.sql, colliding with this branch's version timestamp; renamed to 20260830140000 (references updated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(invoices): stage-time validators mirror the RPC's SEK settlement gate The MCP staging path pre-validates links with validateVoucherForInvoiceLink and validateVoucherForSupplierInvoiceLink before the RPC ever runs, so the new FX residual fallback was unreachable through MCP: the exact case this change exists for. Both validators now mirror the RPC's gate byte-for-byte (migration 20260830140000): accrual only on the customer side, zero readable lines counted per LINE (a zero-amount readable line disables the fallback), every unreadable matched-side line SEK-booked, sane exchange_rate bounds, and the 10 percent deviation band; eligible vouchers validate as a full-remaining settlement, everything else keeps the CURRENCY_MISMATCH refusal with details.reason. Unit tests: fallback settlement, deviation refusal, zero-amount readable line refusal, kontantmetoden refusal, missing-rate refusal, and supplier mirrors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3cf2e10740 |
feat(reminders): per-company reminder text overrides with per-field reset (#2038)
* feat(reminders): per-company reminder text overrides with per-field reset Add company_settings.reminder_text_overrides (JSONB, migration 20260830100000): optional subject/body per reminder level, storing only diffs from the defaults. Reminder templates now express their defaults as placeholder patterns and render stock and override mails through one substitution pipeline (placeholders, HTML escaping, subject sanitizing), so the settings prefill is exactly the sent mail. The level 3 default is strengthened into an explicit inkassovarning (8 days, handover to inkasso, costs per lag (1981:739)); text only, no fee or interest math changes. New ReminderEmailTextsSettings editor (per-level tabs, effective value prefilled, per-field reset, placeholder legend) mounted in the invoicing settings, strings in sv + en, and reminder_text_overrides added to UpdateSettingsSchema with schema and template tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * chore(migrations): bump reminder_text_overrides to 20260830120000 Main gained 20260830101500_seed_agent_atom_bodies after this branch cut its version, so the file moves to a fresh later timestamp to keep remote migration history append-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(reminders): serialize override saves and fix Swedish hint grammar CodeRabbit review: queue the whole-object PUTs in ReminderEmailTextsSettings so an older in-flight snapshot cannot replace a newer edit, and start the level 3 hint with "Den slutliga paminnelsen". The NOT VALID suggestion on the migration CHECK is declined: company_settings is one row per company, migration files run in a single transaction, and the invoice_email_texts precedent shipped the identical constraint shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d11d0a2e90 |
feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553 |
||
|
|
683314b439 |
fix(vat): restore least-privilege ACL on get_vat_ruta_source_lines after #2016 (#2026)
Migration 20260828172003 dropped the 9-arg get_vat_ruta_source_lines (which carried REVOKE FROM PUBLIC, anon and GRANT TO authenticated, service_role) and recreated the 11-arg signature without an ACL, so anon regained EXECUTE by default. This restates the ACL in a new migration and pins it with has_function_privilege assertions in the pg-real test. Follow-up to #2016. |
||
|
|
cc7050f6cb |
fix(pdf): keep the minus sign on losses in standard-font PDFs (#1982) (#1987)
Intl sv-SE formats negatives with U+2212, which the bundled react-pdf Helvetica/Courier fonts cannot render, so a loss printed as a profit. lib/pdf/number-text.ts pdfNumberText maps U+2212 to an ASCII hyphen and prints negative zero unsigned; routed through financial-statement, kassaflodesanalys, reskontra, momsdeklaration, payslip (fmt, the literal Preliminar skatt sign and the calculation formula text) and operational-report templates, with content-stream regression tests. The K2/K3 arsredovisning templates keep main's formatPdfKronor from #2013. Closes #1982 |
||
|
|
338ac4e913 |
fix(vat): make the ruta drill-down reconcile with the figure it explains (#2016)
* fix(vat): make the ruta drill-down reconcile with the figure it explains get_vat_declaration_totals drops four classes of entry before summing: posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and anything shaped like a momsredovisning. The drill-down behind each ruta filtered on company, status and date only. So expanding a ruta listed verifikat that are not in the number it claims to explain, and the panel shows no total that would reveal the mismatch. On production, 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation under BFL 5 kap. and this drill-down is what a consultant uses to substantiate a filed figure, so the two have to agree exactly. The exclusion CTEs are lifted verbatim from the figure rather than re-derived, because any divergence reintroduces exactly this bug. The new pg test asserts the equality for the whole account set at once, so editing one function and not the other fails CI instead of silently misreporting. opening_balance entries are deliberately kept: the figure exempts them from its `shaped` set, which leaves their lines in the totals, so excluding them here would break the equality in the other direction. That has its own test. Verified the test catches the defect by reinstalling the old function body and watching it fail with the real numbers (2611: drill-down 250/240 vs figure 0/200), then restoring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vat): update the existing drill-down pg test to the new signature get_vat_ruta_source_lines gained p_ruta_accounts / p_net_accounts, and production-error-regressions.pg.test.ts still called the old 9-argument form, so pg-real failed with 42883 "function does not exist". I had grepped app/, lib/ and extensions/ for callers and not tests/. Neither fixture in that paging test is settlement-shaped, so paging behaviour is unchanged; the equality itself is covered by the new reconcile test. Also documents, in the tool-pg reset script, that its blanket grant to `anon` (which PostgREST requires) makes that database invalid for the pg-real suite: ~29 of those files assert least privilege and fail there even on unmodified main. That cost a confusing local run. 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> |
||
|
|
1a8fe36bc4 |
fix(reconciliation): direction-aware NULL-link settlement for transfer legs (#2018)
* fix(reconciliation): direction-aware NULL-link settlement for transfer legs A linked transaction with no resolvable cash account counted as settling its voucher for every account. For an own-account transfer on a non-primary bankavstamning card this hid the near leg while the far leg stayed listed, producing a false oforklarat (reported: momskonto card with differens 0 kr but oforklarat -2 593,75). get_account_gl_lines_for_matching now discounts a NULL-attributed link (pointer or junction) only when ALL of: the card is a non-primary account, the voucher touches >= 2 of the company's cash-account ledgers, and the row's sign contradicts the voucher's net leg on the account. All other shapes keep byte-identical legacy behavior, protecting unbackfilled single-leg rows (measured -37 000 kr false-alarm risk under the naive primary-only rule, simulated per-card against prod before choosing this rule; see DECISIONS.md). Footprint measured on prod: 24 vouchers on 7 cards in 6 companies; 5 cards improve (3 to exactly 0,00), 2 surface a real user-fixable mislink that was previously hidden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HwDxhfhYbywQNr8y8a7pUo * test(reconciliation): isolate the primary-card exemption in pg coverage The existing primary-card assertion also passes via sign match; a reverse transfer (1931 -> 1930) whose NULL row contradicts the primary's +net leg pins condition 1 (legacy_null_ok) on its own. (CodeRabbit nitpick.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HwDxhfhYbywQNr8y8a7pUo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7f0f25b558 |
feat(account): self-service login email change with double confirmation (#2017)
* feat(account): self-service login email change with double confirmation New POST /api/account/email requests the change via the user session so Supabase's AAL2 guard applies, and the account settings page gets an email row with pending-confirmation state. Confirmation mails (both addresses) and the /auth/callback email_change verification already existed; this wires the missing initiation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * feat(account): map email_exists to a 409 with Swedish copy Changing to an address that already has an account is refused by GoTrue (addresses are unique per auth user); surface that as a clear conflict instead of the generic fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(account): trusted redirect origin + profiles.email sync trigger (skeptic findings) - emailRedirectTo now derives from resolveRequestAppOrigin(): request.url can be an internal origin behind a proxy (dead confirmation links on self-hosted) and auth links must not follow attacker-chosen hosts; registered white-label hosts keep their brand. - New migration 20260828191950: sync_profile_email trigger mirrors auth.users.email changes into profiles.email (member lists, notification recipients, AGI/KU contact, invite dedup all read profiles.email), plus a backfill for already-diverged rows. pg-real test included. - Save button disabled while the same address awaits confirmation (no rate-limit re-fires); GoTrue's 'error sending email change email' now maps to the Swedish SMTP guidance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(account): idempotent repeat request for the pending address CodeRabbit follow-up: a second POST for the address already awaiting confirmation now returns the pending state without another GoTrue round trip (no duplicate confirmation mails, no rate-limit burn). Claims-mapped sessions lack new_email; GoTrue's send rate limit remains the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a4ceaafa4f |
feat(inbox): per-item underlag anchoring status and a daily reconcile cron for stranded underlag (#1548) (#2012)
* feat(invoice-inbox): per-item underlag status and daily reconcile of stranded booked items (#1548) The inbox derives "booked" from the matched transaction's verifikat, but that says nothing about whether THIS item's document reached it: a link that failed at propagation time, or a document anchored to another verifikat, read as booked while the verifikat sat without its underlag (BFL 5 kap 6-7 §). GET /items and /items/:id now also emit underlag_status (anchored | unlinked | anchored_elsewhere) from one batched document_attachments read; the workspace keeps divergent items in "Att göra", drops the booking bridge for them (the book routes 409 on a booked transaction) and shows one explanatory line with a link to the verifikat. The backfill script's loop moves into lib/transactions/ inbox-underlag-reconcile.ts and runs daily from a new extension-owned cron (vercel.json plus the generated Docker crontabs): transient link failures heal without an ad-hoc script run, permanent conflicts are counted in one summary, and each repaired transaction leaves an InboxUnderlagReconciled row in behandlingshistorik. That event type is registered by migration 20260828154800: processing_history.event_type has an FK to processing_event_types, and the script's previous InboxUnderlagBackfilled type was never registered, so its appends had always failed silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address review findings on the underlag reconcile (#1548) Findings 1, 3, 6 (scan cap starves the tail): the reconcile no longer caps the read. The matched-unconsumed candidate set holds permanent residents (samlingsverifikat siblings, anchored-elsewhere items) that never leave it, so a uuid-ordered read cap would revisit the same 1000 rows every night and never reach a stranded item sorting past the cut. The scan now pages through every candidate (four columns per row) and maxItems bounds the WORK: at most that many unlinked (or unreadable) items are propagated per run; already-anchored, anchored-elsewhere and locked items are counted from the pre-state without a propagation or budget. Items past the budget are counted as deferred and truncated is logged at warn level. Findings 2, 5 (false "linked automatically" promise for locked periods): resolveUnderlagAnchoring reads the fiscal period lock state of the verifikat for every unlinked item and reports unlinked_locked when is_closed or locked_at is set, the same pair enforce_period_lock_documents checks. The reconciler counts it separately (unlinkedLocked), never propagates it and never warns "still unlinked after re-run"; the rail shows a message that says the period must be unlocked first. Findings 4, 7 (absent anchoring read as booked): the list and detail enrichment emit underlag_status 'unknown' when the helper could not read the document row, and the workspace treats any status but 'anchored' as divergent (stays in Att göra, no booking bridge, own message). classify() counts a repair only when the pre-state was explicitly unlinked, so an unreadable before-read never earns an InboxUnderlagReconciled event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address round-2 review findings (#1548) 1. [minor] Round-1 fix dropped propagation for transactions whose inbox items already read anchored, so the pinned-document leg (transactions.document_id) was never repaired and settled items never received their created_journal_entry_id stamp, staying in the scan and inflating alreadyAnchored every night. reconcileCompany now propagates every stranded transaction that has an unlinked (budgeted) item or an anchored / document-less item, outside the maxItems budget: the helper is idempotent and the stamp shrinks its own population. Locked-only and anchored-elsewhere-only transactions stay skipped. Counting and the behandlingshistorik trail are unchanged (anchored items keep their pre-state verdict, no event). Tests updated and a new case pins the anchored-item plus document-less-item transaction: propagated, no after-read, no history. DECISIONS line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ad8566f1ae |
feat(settings): per-company data-analysis opt-in gating the calibration corpus (#1346) (#2007)
* feat(settings): per-company opt-in for data analysis of bookkeeping outcomes (#1346) Adds company_settings.data_analysis_opt_in (default false, no grandfathering) and gates every path that reads bookkeeping outcomes across companies on it: POST /api/agent/categorize/outcome stops writing calibration samples for companies that have not opted in, and the backtest / calibration-fit scripts filter to opted-in company ids. One helper (lib/company/data-analysis.ts) is the single gate for future analysis paths. A toggle on Inställningar > Företag states plainly what is analysed (proposed vs booked account, amount, confidence; no free text, no personal data) in sv and en. The flag is UI-only by design: consent is a human action, so it is absent from the v1 REST / MCP settings pick lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(settings): make data-analysis consent copy true for the backtest path (#1346) Addresses adversarial review findings on PR #2007: - Findings 1-3 (consent narrower than the gated processing): the flag also gates scripts/backtest-categorize.ts, which re-runs transaction descriptions, merchant names and matched underlag through the model. The sv/en toggle help and disclosure now state that explicitly as "evaluation runs" and no longer claim that free text or underlag are excluded. The migration header and COMMENT, the lib/company/data-analysis.ts docstring, the backtest script header and the DECISIONS line say the same. Kept the gate (un-gating would put the script back to reading every company with no consent at all). A test pins that both locales name those inputs and contain no "no free text / no underlag" denial. - Finding 4 (member sees an active switch that RLS rejects): the toggle is now enabled only for owner/admin, matching the company_settings update policy; the disclosure says only administrators can change the choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): address round-2 review findings (#1346) 1. [minor] Opted-in company filter was an unbounded PostgREST `in` list in the URL (scripts/fit-categorize-calibration.ts, scripts/backtest-categorize.ts). Both scripts now read the opted-in ids through a shared, paginated helper (listDataAnalysisOptedInCompanyIds, fetchAllRows so the pre-fetch no longer caps at 1000) and query per chunk of 100 ids (chunkCompanyIds). The fit script pages each chunk on the id PK; the backtest merges per-chunk results and re-cuts to the N most recent overall. Early exit on zero opt-ins is kept. Pinned with tests in lib/company/__tests__/data-analysis.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): coerce a null transaction description in the backtest (#1346) The typed row from the chunked consent query made description nullable, which TransactionForSelect does not accept; fall back to the original description or an empty string, as the untyped row did implicitly before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
57d4359d1a |
feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
533df34369 |
fix(payments): make supplier payment batch creation atomic via create_supplier_payment_batch RPC (#1989)
createSupplierPaymentBatch wrote the batch header and its items as two separate PostgREST inserts, and the active-batch recheck ran app-side before either. Two concurrent creates selecting the same invoice could both pass that check and both land an active batch without confirm_already_batched, and an item-insert failure after the header landed could leave an empty 'created' batch behind when the best-effort cancel also failed. The new SECURITY DEFINER RPC is now the single write path: it locks the selected invoices FOR UPDATE in id order, re-checks payability, amounts and active batches inside the transaction, and inserts header + items together so a constraint violation rolls both back. TypeScript keeps the shared eligibility evaluation and the msg_id minting (branding lives in TS); the service result union is unchanged so the route and UI are untouched. Closes #1503 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4f6ecad549 |
feat(white-label): invite-only signup for brand domains (#1995)
* feat(white-label): invite-only signup for brand domains
A brand domain belongs to the partner's people (founder decision
2026-08-27): only allowlisted or invited users may create an account on
an invite-only brand domain; everyone else is shown an interstitial that
sends them to the canonical Accounted signup.
- brands.signup_mode ('open' default / 'invite_only') +
brand_signup_allowlist (lowercase emails, team-scoped RLS, owner/admin
writes) + create_company_for_brand_signup RPC, with pg-real coverage
- server-side gate (lib/auth/brand-signup-gate.ts) enforced on every
signup path: email signup moved to POST /api/auth/signup (the browser
used to call GoTrue directly, so a client-side check would be
bypassable), BankID gated in /bankid/complete, Google covered by the
dashboard layout's brand-domain bounce
- company invites bypass the allowlist: the invite is the authorization
- register page interstitial on gated brands (no email in the outbound
URL), sv+en strings
- dashboard layout bounces non-belonging sessions off gated brand hosts
to the canonical domain (navigation rule like WL-01, not a security
boundary)
- allowlisted signups' onboarding-created companies attach to the
brand's byra team via the new RPC, so WL-01 homes them on the brand
domain; the allowlist entry recorded by an owner/admin stands in for
the WL-15 admin gate
- byra cockpit page /clients/access + /api/clients/signup-access to
manage the mode and the allowlist
All existing brands default to 'open': behavior is byte-identical until
a brand is flipped to invite_only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): rollback brand-signup company with the service client
Skeptic (correctness) found that a brand-signup company created under the
service role rolled back with the cookie-session client: `companies` has
RLS and no FOR DELETE policy, so the delete was a silent 0-row no-op,
stranding a member-less ghost company on the partner's byra team. Pass an
optional rollbackClient to createCompanyCore and hand it the service
client on that path; user_preferences.active_company_id then clears itself
via its ON DELETE SET NULL FK once the company row is actually deleted.
Also map a validateBody 400 (flat envelope, no code) on the register page
to the specific email-invalid field message instead of the generic one,
since the client already pre-gates password strength.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): fail-safe brand lookup, pg-test seed, anonymize fixtures
Second resolve-pr cycle: skeptic + CodeRabbit findings and a green-up.
- Fail safe on a brands-table error (CodeRabbit CWE-285): the gate treated a
failed resolveBrandByHost as an unbranded host, opening invite-only signup
during a transient DB blip. resolveBrandResultByHost now distinguishes
"no brand" from "lookup failed"; the gate returns lookupFailed and the
email + BankID routes answer 503 (retry), never creating an account.
- pg-real: the RLS delete test seeded its row inside withUserContext, which
always rolls back, so the owner DELETE saw zero rows. Seed on the superuser
pool instead.
- Anonymize every test/fixture brand to the repo's existing synthetic
placeholder (Siffra / app.siffra.se): no real partner names in code.
- SignupAccessManager: functional setData updates so a concurrent mode
toggle and an add/remove do not clobber each other's snapshot (CodeRabbit).
- Route a transient-error message through i18n instead of the raw envelope
(raw-user-error guard); new register.error_temporary sv+en.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* test(white-label): anonymize new signup-gate fixtures; log oracle residual
Rename the placeholder brand in the four new brand-signup test files to a
clearly-fake, partner-unrelated name (Testbrand / app.testbrand.example);
the previous placeholder echoed a real partner. Scoped to files this PR
creates; the repo-wide legacy placeholder is left for a separate cleanup.
Also record in DECISIONS.md that the feature ships accepting the
low-severity allowlist-enumeration residual (captcha-free 403 vs 200 on
the signup endpoint), with rate-limiting as the follow-up option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
325c827322 |
test(mcp): run the tools against a real PostgREST, not a fake supabase (#1983)
All 100 files in extensions/general/mcp-server/__tests__ fake supabase. query-journal.test.ts says out loud that its query chain is "exercised by the live MCP smoke test", and no such test exists in CI. So the PostgREST grammar of 157 tools, every .select() column string, every resource embed, every or=(...) form, is gated by nothing and fails first in production. pg-real cannot cover this: it holds a pg Pool and writes SQL, and none of that grammar is resolved by Postgres. It is resolved by PostgREST at request time. Adds a tool-pg vitest project, a docker-compose stack, a reset script that replays every migration the way the pg-real CI job does, and a CI job. The first sweep covers 74 read tools and finds no malformed query, across 87 real requests. That number is honest rather than impressive: with an empty argument set many tools bail before querying. Per-tool fixtures are what deepen it, and this harness is what makes writing them worth the effort. Includes a self-test that injects a bad column and asserts the harness detects it. That is not ceremony. It caught this file passing green while exercising nothing, twice: once locally where supabase-js prefixes /rest/v1 onto a bare PostgREST that does not serve it, and once on CI where Node 20 has no native WebSocket, so every client construction threw and was swallowed by the per-tool catch as a domain refusal. The client is now built once outside that catch, the proof-of-life assertion counts real requests instead of being trivially satisfiable, and realtime gets an inert transport. Also excludes .next from all three vitest projects. These projects override vitest's default excludes, so a local `npm run build` leaves a traced copy of the repo that gets collected as a second set of test files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c0ecb34a2b |
fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices (#1977)
* fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices The 2026-08-18 eligibility rule stopped the archive-and-replace reset before the first journal entry, voucher sequence, or invoice. The reset deletes nothing: the source stays a write-closed, downloadable retention container, and the unchecked Radera foretag path already produced the same outcome without any of those guards, so the blockers only led owners into a dead end (Carrierstories, 2026-08-26). External-state blockers are unchanged. Closes #1916 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): move the migration-reset entry to the end of the log 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> |
||
|
|
fdb5f6f891 |
feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
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> |