1d63e0f72b3d0987c188a48bb8eb8ce07aee70dc
907
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
f216a60bf8 |
feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fc54e79e08 |
fix(auth): scope the home-domain ok cookie to the signed-in user (#2083)
The gnubok-home-ok cache cookie stored only the host, so any account that signed in within the 15-minute TTL window inherited the previous user's "this is home" verdict in the same browser and skipped the brand-host bounce entirely: a user with no ties to a white-label brand could land inside the branded shell instead of being redirected to the canonical domain (amnas account-switch repro, two logins 9 s apart). The cookie value is now userId~host and the middleware only skips the affinity check when both match the current session. Old host-only cookies never match, so the check re-runs and the format self-migrates; no sign-out hook is needed because a foreign verdict misses by construction. Claude-Session: https://claude.ai/code/session_01EBJAE7b6B64HaKybjaC66B Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6dfaa45061 |
feat(notifications): opt-in daily "nytt att bokföra" email digest (#2078)
* feat(notifications): opt-in daily "nytt att bokföra" email digest Users asked for an email when new work arrives: bank transactions that synced overnight and documents that landed in the inbox. Adds a daily 05:45 UTC cron (after the 05:00 bank sync) that emails opted-in users a per-company summary with counts only, no amounts (data-minimization stance of the kvittens/skattekonto mails). - notification_settings.email_digest_enabled, NOT NULL DEFAULT false: strictly opt-in via a new toggle in the notification settings panel - notification_log type 'bookkeeping_digest' with the claim-then-send partial unique index pattern: one mail per user per company per day - counts unbooked transactions and unprocessed inbox items created in the last 24h; empty digests are never sent - brand-aware sender + link base via lib/email/brand-sender - docker crontabs regenerated from vercel.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(notifications): digest review findings in one pass Skeptic + bot findings on PR #2078, resolved together: - Count queries now match the canonical worklist anchors: ignored transactions excluded; inbox items already booked directly or matched to a transaction (created_journal_entry_id / matched_transaction_id) no longer counted (skeptic: spurious digests). - Memberships sweep and member-email lookups chunk .in() id lists at 150 ids to stay under proxy URL limits (HTTP 414 at ~350 opted-in users). - Recoverable claim lifecycle (CodeRabbit): claim inserts as 'pending', flips to 'sent' only after the provider accepted the mail; a stale pending claim is atomically taken over by a later run, so a worker death mid-send no longer swallows the day's digest. New migration 20260831110000 admits 'pending' to the delivery_status CHECK. - Company name sanitized against CRLF header injection before the mail subject (compliance swarm ASVS V1.2.5), with test. - RoPA entry for the new processing activity in .compliance/ropa.yaml (compliance swarm GDPR Art. 30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(types): admit 'pending' to NotificationLog delivery_status union Matches migration 20260831110000; surfaced by fix re-verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
024420d655 |
fix(auth): exempt allowlisted emails from home-domain brand-host bounce (#2077)
A signup-allowlisted partner owner (brand_signup_allowlist) could sign up on an invite-only brand domain but was then exiled to the canonical domain by the middleware home-domain affinity rule before any team membership existed: the layout-level exemption in resolveBrandDomainBounce was unreachable because the middleware redirect runs first. Rule 2 now checks the allowlist (fail-closed) and lets those sessions stay, caching the verdict like other home verdicts. Found via the Ziffr owner lockout. Claude-Session: https://claude.ai/code/session_011eQDGWLppyy6BZ6Rx6shjd Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a3686dd45 |
feat(inbox): promote a single prominent amount into the editable total (#2073)
* feat(inbox): promote a single prominent amount into the editable total Follow-up to #2048 after founder review: the Belopp row was load-bearing for matching but read-only, so a misread amount could not be corrected, and an empty TOTALT still read as "extraction failed". - promoteSingleProminentAmount (extraction post-step, all intake paths): documentKind other/government_letter with no total and exactly one distinct nonzero prominent amount gets it copied into totals.total, stamped totalSource: 'prominent'. Multi-amount documents are left alone: picking one silently would invent a total. - provenance keeps the safety rails: matching demotes a promoted total back through the prominent-amounts fallback (0.85 discount, date guard, amountSource tag), so the nightly receipt-hunt still excludes these documents and confidence never presents as certainty. - the fields-PATCH route clears totalSource when a human edits TOTALT: a user-set amount is a verified total at full weight. - the read-only Belopp row now renders only for multi-amount documents, and filters zero amounts ("Totalt manadspris: 0 kr" noise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): review pass: concurrency-safe fields PATCH, zero-amount predicate CodeRabbit findings on #2073: - the fields-PATCH read-merge-write could let a racing autosave restore a stale extracted_data blob (including a totalSource stamp a concurrent TOTALT edit had just cleared). The update is now conditional on the trigger-maintained updated_at; zero rows matched returns 409 and the client's next debounced save re-reads. - hasAnyExtractedField now uses the same meaningful-amount predicate as the Belopp render filter, so a zero-only prominentAmounts list no longer suppresses the retry / upgrade affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6a40b3c0ef |
fix(inbox): name the FK in the underlag lock-state embed so locked items classify (#1548) (#2022)
fiscal_periods carries two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so the bare fiscal_periods(...) embed in resolveUnderlagAnchoring is ambiguous on prod and every lock-state read failed on the first underlag-reconcile cron run: 34 locked-period items were treated as retryable and hit the period-lock trigger daily. The embed now names journal_entries_fiscal_period_id_fkey (same hint the MCP server uses) and the exact select string is pinned in the unit test, since mocks cannot see PostgREST ambiguity. Follow-up to #2012 (issue #1548). |
||
|
|
502905e093 |
feat(fortnox): make scope-approval flags overridable via env for self-hosted apps (#2069)
FORTNOX_DOCUMENT_SCOPES_APPROVED and FORTNOX_ASSET_SCOPES_APPROVED read env vars of the same name with the hosted registration's values as defaults, so a self-hosted Fortnox app avoids invalid_scope at authorize and the impossible reconnect loop without patching source. Supersedes #2001. Co-authored-by: Pierre Gronberg <pierre@hbgdesignlab.se> |
||
|
|
8b0aa80ea0 |
feat(arcim-migration): import the Fortnox asset register during migration (#1999)
The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row. Co-authored-by: pgronberg <pgronberg@users.noreply.github.com> |
||
|
|
6ac9679fb5 |
feat(auth): base available login methods off GoTrue providers (#1869)
* feat(auth): base login fields on GoTrue providers Signed-off-by: Goostaf <gasplund2@gmail.com> # Conflicts: # app/(auth)/login/login-client.tsx # app/(auth)/register/page.tsx * fix: address feedback Signed-off-by: Goostaf <gasplund2@gmail.com> * chore: remove hardcoded Google enabled checks Signed-off-by: Goostaf <gasplund2@gmail.com> # Conflicts: # .env.example * feat: show label when password login is disabled Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: use MicrosoftMark, correct comment Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: display custom providers Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: show when no methods are available Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: display custom provider labels Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: add SAML login path Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: show when no methods are available Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: display SAML button when enabled Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: preserve nextPath and broken key Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: redirect test to client Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: expose registerEnabled Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: provider allowlist and request timeout Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: restore compact labels Signed-off-by: Goostaf <gasplund2@gmail.com> * refactor: move withTimeout implementation to utils Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: include nextPath Signed-off-by: Goostaf <gasplund2@gmail.com> * fix: export function and test case Signed-off-by: Goostaf <gasplund2@gmail.com> * feat: only show SAML button if vars configured Signed-off-by: Goostaf <gasplund2@gmail.com> * fix(auth): map SAML sign-in error through getErrorMessage The antipattern ratchet (check:guards, raw-user-error) rejects a raw error.message reaching a user-visible sink. Route the signInWithSSO error through getErrorMessage like the other auth error paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * feat(auth): GitHub brand mark on the provider button; decision log GitHub allows its invertocat in solid black/white, so currentColor is correct; custom OIDC providers keep the generic key icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Goostaf <gasplund2@gmail.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> 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
|
||
|
|
516e8b62ff |
feat(inbox): match non-invoice documents via prominent amounts (#2048)
* feat(inbox): match non-invoice documents via prominent amounts Bankintyg, bank agreements and other documentKind "other" PDFs carry no invoice-style total, so extraction correctly left totals.total null and the document became structurally unmatchable: findUnderlagCandidates hard-drops items without a comparable amount and the picker lost the 40% amount signal. - extraction: new prominentAmounts[] field (amount + document's own label), populated only when totals.total is null; account/org/phone/reference numbers and zero amounts excluded. totals.total semantics untouched. - matching: bestProminentAmountVariance() tries each printed amount and feeds calculateMatchConfidence at reduced weight (0.3 vs 0.4) in both the agent candidate scorer and TransactionMatchPicker. - UI: inbox rail shows the detected amounts read-only for such documents, list falls back to a single distinct prominent amount, and extraction no longer reads as "found nothing". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): discount prominent-amount fallback instead of reweighting it Skeptic pass refutations on the first commit: normalized weighting made a reduced amount weight self-defeating. Date + exact fallback amount with no merchant scored (0.25+0.3)/0.55 = 1.0 ("100% sakerhet" on a wrong same-day transaction), and a DISAGREEING fallback amount scored above a disagreeing invoice total (0.67 vs 0.60) because shrinking the weight also shrank the penalty. - score fallbacks at full amount weight, then multiply by a flat FALLBACK_CONFIDENCE_FACTOR (0.85): agreement caps below certainty, disagreement stays at least as damning as for a real total. - agent candidate surface additionally requires the document date within DATE_TOLERANCE_DAYS, so an avtal listing 349 kr no longer matches every future 349 kr charge from the same counterparty. - bestProminentAmountVariance returns which amount matched + its document label, and the match reason names it ("Exakt belopp i dokumentet: 2 500 SEK (Engangspris)"): no more bare "Exakt belopp" reaching the agent while total_amount is null. - prompt: prominentAmounts restricted to non-invoice documentKinds, and never a parking spot for an unreadable invoice total. - fix the stale "deliberately the same list" comment on EXTRACTED_FIELD_ACCESSORS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(receipt-hunt): never propose on the prominent-amounts fallback Second skeptic pass: the nightly hunt is a third consumer of scoreUnderlagCandidates and inherited the fallback unaware. A bankintyg whose printed "Insatt belopp" equals a same-day outflow scores 0.85, which clears CERTAIN_CONFIDENCE (0.8) and skips LLM adjudication, on a pairing wrong by construction (the hunt scans outflows only; "Insatt belopp" labels an inflow), with document_amount null in the approval preview. UnderlagCandidate now carries amountSource ('total' | 'prominent') and selectProposals drops fallback-scored candidates. Non-invoice documents stay reachable through the manual picker and the agent candidate surface, both of which have a human reading the amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): round fallback confidence via roundOre, not the naive pattern The two confidence discounts (and their test) tripped the naive-ore-round antipattern ratchet (625 vs baseline 622); use the sanctioned helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84ba8323b4 |
fix(transactions): derive pre-migration marker cutoff from imported voucher dates, not fiscal_year_end (#2047)
* fix(transactions): derive pre-migration marker cutoff from imported voucher dates, not fiscal_year_end A SIE file exported mid-year still declares the full fiscal year in #RAR 0, so sie_imports.fiscal_year_end is a future date for mid-year migrators and the 'fran perioden fore din migrering' marker fired on every new bank transaction until New Year. The cutoff now comes from the latest posted source_type='import' entry_date (excluding the M-series omforingsverifikation, which is deliberately dated at fiscal year end), so it tracks where the imported bokforing actually ends and self-corrects on undo/replace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3 * fix(transactions): arm pre-migration cutoff on completed SIE import, exclude omforing by description Skeptic findings on the frozen commit: (1) source_type='import' is accepted from v1 API clients, so a never-migrated company with an API-labeled backfill would get a false cutoff; the marker is now armed only when a completed sie_imports row exists. (2) Imported vouchers keep the source file's voucher series, so excluding series M dropped genuine M-series vouchers (6 prod companies); the omforingsverifikation is now excluded by its hardcoded description prefix instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3 * docs(transactions): state the pre-migration cutoff residuals truthfully The ledger duplicate guard only reaches the completed-year single-skip case (7-day window vs a fiscal-year-end-dated aggregate omforing), so it is not a general backstop for the skip-window gap; the gap is accepted on rarity. Also documents the rattelse-rename fragility of the description-keyed exclusion. Skeptic re-review condition, no code change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
e92b5a59b2 |
fix(reminders): settings UI discloses that automatic sending is disabled (#2033)
* fix(reminders): settings UI discloses that automatic sending is disabled The invoice reminder cron has answered 503 since May 2026 (PR #583), so no automatic reminders are sent, but the settings UI still let users configure reminder day levels as if sending worked. Introduce REMINDERS_SENDING_ENABLED (lib/invoices/reminders-enabled.ts) as the single shared flag read by both sides: the cron route uses it as its 503 gate (with the original sending pipeline restored behind it, so re-enabling later is one flag flip), and the invoice settings form shows an attn notice while the flag is off. Schedule settings stay editable; notice strings added to both sv and en locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * docs(reminders): correct the re-enable contract after skeptic review The flag docblock, route docblock, and test header claimed flipping REMINDERS_SENDING_ENABLED alone resumes sending. False on hosted: the route has had no vercel.json cron entry since PR #559 and the crontab ratchet pins it in INTENTIONALLY_UNSCHEDULED, and POST requires the cron secret so no dashboard can trigger it. Rewrite the claims into the real re-enable checklist and record the pre-flip prerequisites surfaced by review: invoice_reminders lacks a unique (invoice_id, reminder_level) constraint and the fee entry is booked before the reminder row, so a run dying mid-batch double-books the fee; the backlog would get highest-level reminders first. Comments and a test name only; no runtime change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
341d61131a |
fix(auth): email-change recovery re-send and confirmation feedback (#2034)
* fix(auth): email-change recovery re-send and confirmation feedback A half-completed secure email change was a dead end: the pending-address short-circuit in /api/account/email swallowed every retry without re-sending mails, so once the confirmation links expired the user could never recover, and confirmation clicks landed on the dashboard with no feedback at all. - /api/account/email: only short-circuit a repeat request while the pending mails are fresh (30 min); a stale pending change falls through to GoTrue, which restarts the change and re-sends both mails - /auth/callback: type=email_change now redirects to a status page (/auth/email-change) that says whether one click remains, the change is complete, or the link was dead, instead of landing silently - auth mail templates: both email-change mails explain that two mails are sent and both links must be clicked - settings: the save button re-enables for the pending address as Skicka igen, so users can trigger the re-send themselves Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sbGMZQE5W7KfSVFjK7E4p * fix(auth): exempt email-change confirmations from the authenticated /auth bounce (skeptic findings) - middleware: let /auth/email-change and /auth/callback?type=email_change through for authenticated users; the bounce to / swallowed confirmation clicks before verifyOtp ran (pre-existing since #2017) - email-change done page resolves the WL-14 landing destination for the CTA - /api/account/email returns resent flag; settings toast says mails were already sent instead of claiming a fresh send 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> |
||
|
|
9f8fa1b692 |
feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval
Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.
- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
with an explicit userId param (service-role clients null auth.uid());
the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
approval, risk 'high' (both outcomes irreversible, never
auto-committed), catalogVisibility 'search' (tools/list budget at zero
headroom)
- delete_draft_invoice commit executor delegating to the shared service,
plus pending_operations CHECK constraint migration pair
(20260830100000/100001), risk tier, scope map, Granskning vocabulary
and sv/en labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main
Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint
apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(invoices): pin staged delete outcome and align v1 risk metadata
Skeptic findings on PR #2036:
- Outcome pin: gnubok_delete_draft_invoice stages
expected_invoice_number alongside invoice_id; the executor passes it to
deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
draft's number changed since staging. An unnumbered draft finalized
between staging and approval is now auto-rejected with a message naming
the new number, instead of silently switching from the approved hard
delete to a makulering. Ops staged without the pin keep legacy
semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
the delete_draft_invoice pending-op tier (both outcomes irreversible);
generated accounted-api docs regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision
Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
---------
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> |
||
|
|
8f68421fef |
feat(skatteverket): expose skattekonto row booking via MCP staged operations (#2039)
* feat(skatteverket): expose skattekonto row booking via MCP staged operations Skattekonto READ and RECONCILE were already MCP tools, but BOOKING rows existed only behind the cookie-session extension routes. This closes the flow on API-key surfaces: - New staged MCP write tools gnubok_book_skattekonto_row and gnubok_book_skattekonto_rows (batch, 1-200 ids), catalogVisibility 'search', STAGED_OPERATION_SCHEMA, stage-time bookability gates and rule-matched counter-account preview. Staging never books. - New pending-operation types book_skattekonto_row / book_skattekonto_rows (CHECK constraint migration pair, risk tier medium, sv/en labels). - Commit executor reaches the skatteverket extension through the registry-resolved services channel (core never imports @/extensions): new commitBookSkattekontoRows service wraps the SAME bokforSkattekontoTransactionsBatch helper the HTTP bokfor-batch route uses (draft + commit per row via the bookkeeping engine, requireSettled), with the approving user id passed explicitly (auth.uid() is NULL on the service client). No booking math or account mapping changed: auth-surface exposure only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * chore(migrations): move book_skattekonto constraint pair after main's newest version origin/main gained 20260830101500 while this branch was in flight; keep the new pending_operations constraint migrations sorted after it so they apply in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(skatteverket): resolve CI and review findings for skattekonto booking tools - Inline the skattekonto_transactions select strings at both stage-time call sites so the no-phantom-columns scanner can resolve them (the shared const pushed the unresolvable-expression count past the ceiling and failed Unit tests 3/4). - Return a fixed public message from the commitBookSkattekontoRows batch-level catch instead of the raw exception text; the raw error stays in the server log (Superagent P2). - Add verifikat_description to the staged previews so the reviewer sees the exact ledger text the booking helper writes, Skatteverket motpart included (Swedish accounting review). 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> |
||
|
|
e313bfa8ec |
fix(invoices): ROT/RUT kontantmetoden invoices could not be marked paid (#2040)
* fix(invoices): derive mark-paid amount as customer settlement, not gross debit sum remaining_amount on a ROT/RUT invoice is stored net of the deduction (total - deduction_total): the customer owes only their share, and Skatteverket's share sits on 1513 until the payout flow clears it. The kontantmetoden payment entry correctly books two debit legs (bank = customer share, 1513 = deduction), but both mark-paid routes summed ALL debit lines as the payment amount, so the gross total was compared against the net remaining and every ROT/RUT cash invoice was rejected with MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total, stalling the whole ROT chain (unpaid invoice never becomes a payout candidate). New deriveCustomerSettlementAmount in lib/invoices/apply-invoice-payment excludes the net 1513 debit, capped at the invoice's own deduction (so invoices without a deduction keep byte-identical behavior, including rejecting a hand-added 1513 overshoot), and both the dashboard and v1 mark-paid routes use it. The verifikat still books the full entry including the 1513 leg; only the settlement math changes. Reported by a user unable to mark ROT invoice 1123 as paid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ * fix(invoices): harden ROT settlement derivation per skeptic refutations Three fixes from adversarial review of the previous commit: 1. v1 mark-paid never fetched deduction_total (the pre-flight select projects explicit columns), so the exclusion cap was always 0 and the v1 API still failed with MATCH_AMOUNT_EXCEEDS_REMAINING. Fetch it ad hoc next to journal_entry_id (kept out of the response contract) and assert the projection in the route test, since the mock harness ignores select strings. 2. Gate the 1513 exclusion on the invoice NOT being booked yet, in both routes. An invoice booked at send already debited 1513 in its registration entry; ungated, a cash-shaped payment entry on such an invoice would post (orphaned 1510, doubled 1513, double revenue and VAT) where the gross guard used to reject it. 3. payment-sync's reversal recompute now stores remaining_amount net of deduction_total, matching build-invoice-write and the DB guard. Recomputing gross made a storno'd ROT cash invoice permanently un-payable under the net settlement derivation (net payment can never reach a gross remaining; the cash-partial block rejects the rest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ --------- 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 |
||
|
|
749f90fe62 |
feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)
Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551 |
||
|
|
523fba0419 |
feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated. |
||
|
|
521f437072 |
feat(migration): link migrated invoices to their registration voucher (#1463) (#2024)
Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463 |
||
|
|
24614c19ff |
fix(cash-accounts): rebind movable transactions when remapping a PSD2 ledger (#2023)
When a PSD2 remap collides with an overflow duplicate cash account (1931 next to a promoted 1930), the unbooked, unmatched, non-anchored transactions on the duplicate are rebound onto the promoted row before the duplicate is resolved, so booking stops proposing the dead ledger. Rows that are booked or anchored via transaction_voucher_links / invoice_payments / supplier_invoice_payments stay put and the duplicate is demoted (never deleted) while any remain, as before. Supersedes #1756. Co-authored-by: Daniel Stenborg <daniel@stenborg.se> |
||
|
|
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 |
||
|
|
4b7343d5ec |
fix(errors): keep the SQLSTATE when wrapping database errors (#2027)
isTransientFailure() checks the driver's error code first, and 57014
(statement timeout) is already in its transient set. But the wrapping idiom
across the codebase was `throw new Error(\`Database error: ${err.message}\`)`,
which keeps the prose and drops the code. A retryable timeout therefore
arrived anonymous and resolved to UNKNOWN_ERROR: "Något gick fel. Försök
igen." An agent cannot dispatch on that, so it retried.
On production over 60 days, with the two bot integrations excluded: 1024 real
agent failures, 645 of them UNKNOWN_ERROR across 60 actors and 57 companies.
82 retry streaks of three or more identical failures, 462 wasted repeat calls,
53.1% of all agent error calls sitting inside a streak.
The worst offender traces to one line in core. gnubok_query_journal failed 164
times at a p50 of 8110ms while every other failing tool sat between 1 and
315ms, and its path is fetchEntryLines -> fetchAllRows, where
lib/supabase/fetch-all.ts threw `new Error(error.message)`. That is the
highest-traffic strip point in the repo: 31 callers, every paginated read.
query_journal already had a correct TRANSIENT_ERROR branch offering "retry, or
narrow with date_from/date_to" which could never fire, because by the time it
looked, the code was gone.
fetch-all keeps the driver message verbatim: callers match on the existing
text, and this adds the code rather than rewording anything.
Attaching the code is safe. extractCode() only accepts /^[A-Z_]+$/ and every
SQLSTATE contains digits, so it cannot be mistaken for one of our own stable
codes. There is a test for that, and one asserting the old bare-Error shape
still resolves to UNKNOWN_ERROR so the fix cannot silently regress.
Also stops rendering the literal "undefined" when a driver-level failure
carries no message, which is the string that made these unsearchable.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
4e1eb3d662 |
fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643) A broken bank reconnect leaves cash_accounts rows that share the live account's IBAN (held by a revoked connection, or demoted to manual by the #916 fix). Three consequences are fixed here: - Problem 4 (silent mis-booking): the own-account transfer detector paired with such an orphan and proposed its ledger as the counter-account, and a counterparty template learned from that result replayed as 1940/1931 in the booking dialog. The detector now tolerates several rows on one IBAN, never pairs with the transaction's own row, a disabled row, or a revoked holder; the mapping engine drops a "transfer" whose counter equals the settlement account; suggest-categories withholds learned suggestions that reference an orphaned ledger; and both commit paths (POST /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are only refused in the COUNTER position: a stranded row still settles on its own ledger, and a manual account without a live IBAN twin is never treated as orphaned, so transfers between two live accounts keep booking. - Problem 1 (match dialog): the ranked unmatched-entries path also offers vouchers booked on sibling ledgers of the same IBAN, and manualLink accepts a voucher line on a sibling ledger. When it does, the same locked UPDATE re-points transactions.cash_account_id to the live sibling row (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the account-keyed reconciliation does not count a cross-account link as an imbalance on both ledgers. - Problem 3 (naming): allocatePsd2LedgerAccount names the chart account BAS-style (BAS reference name for a standard slot, else "Bankkonto <CUR>") instead of the ASPSP-reported holder name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643) One in-memory topology (cash_accounts rows + bank_connections status) now defines "live", "orphaned" and "same physical account" for the transfer detector, the match/link flows and every commit guard, so a proposal is never made that a guard later rejects. - Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban treats the transaction's own IBAN as "not a transfer": every same-currency row on that IBAN is the same physical account, whichever is live, so interest stamped with the own IBAN never pairs with a twin (two active rows, a demoted-manual twin, or a live twin of a stranded row). Only a pocket in another currency on that IBAN can still pair. guardCounterLegs refuses a same-IBAN same-currency twin in the counter position on every commit path, even when both rows are active. - Finding 3: with several surviving candidates (currency pockets with no discriminator, or two active twins) the finder returns null instead of picking the lowest ledger, which is what the pre-PR lookup did. - Finding 5: the finder drops every row in the orphaned set, the same predicate the commit guards use (demoted-manual twins included). - Finding 9: "live" means enabled + connection status 'active'; an expired/error twin of a live row is orphaned, a lone expired connection (re-auth window) is not. - Finding 2: siblings are keyed on (normalized IBAN, currency) in describeCashAccountSiblings and the unmatched-entries route, so a SEK transaction can no longer link to a voucher whose only bank leg is on the EUR pocket of the same IBAN; manualLink rejects that as before. - Finding 8: manualLink re-points a row only when the voucher sits on the LIVE sibling and the own row is not live; the reverse direction links without moving the row. - Finding 7: the v1 REST categorize route runs the same guardCounterLegs check after account_override and returns TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through categorizeMatchedTransaction, already covered. - Finding 10: a learned template whose stale 19xx leg is a twin of the settlement row is rewritten to the settlement account (it is the bank leg, not the counter) instead of refused; suggest-categories exempts each transaction's own settlement ledger before withholding a suggestion. The error message now covers both the twin and the disconnected case. Tests pin each behavior (service, detector, manualLink, unmatched-entries, dashboard and v1 categorize routes, suggest-categories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-2 review findings (#1643) 1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology now keys the live twin on normalized IBAN plus currency (the rule every other "same physical account" check in the PR already used), so a manual or deselected GBP/EUR pocket beside a live SEK pocket of a multi-currency account is never orphaned, still pairs in the transfer detector and is accepted as counter at commit. Twin computation is shared (twinLedgersOf). 2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is a twin of the transaction's own row is rewritten to the settlement ledger in the offered suggestion instead of being withheld; only a true counter-position orphan (or a twin that would book the settlement ledger against itself) is withheld. One topology load per batch (loadCounterLegTopology). 4. The free-form dialog path (POST /api/transactions/[id]/book) gets a line-level guard (guardBookedCounterLines): a 19xx line that is a twin of the transaction's own row or an orphaned ledger, alongside the settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. Only runs when the lines touch two distinct 19xx ledgers. The twin rewrite in suggest-categories (2) covers the both-active shape before the dialog is even opened. 5. manualLink re-points the row onto the sibling ledger the voucher was booked on whenever the sibling is live or the own row is not (both-live twins and both-dead rows included); only a live row whose voucher sits on a dead sibling links without moving. unmatched-entries now uses describeCashAccountSiblings and does not offer dead-sibling vouchers to a live row. DECISIONS.md: the PR's existing review follow-up line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-3 review findings (#1643) 1. Revoked-held rows are no longer orphaned unconditionally. A row whose connection is revoked is orphaned only under the twin rule (not live AND a live row shares its normalized IBAN + currency), so a disconnected-but-real account (the company's only 1930, or two real accounts on one revoked connection) stays pairable by the transfer detector and bookable as counter on every guarded path. Tests cover the no-twin case for getOrphanedCounterLedgers, findPairableCashAccountByIban, detectOwnAccountTransfer, guardCounterLegs and guardBookedCounterLines; the existing revoked tests now use a twin shape. 2. manualLink / unmatched-entries decide the re-point on the destination: a new shouldRepointToSibling moves onto a live sibling, or onto a dead one only when the own row's holder is gone (released: bank_connection_id null or revoked) and no sibling is live. An expired/error/pending own row links without moving. SiblingCashAccount gains `released`. Tests: expired own row + demoted twin links without moving and the twin's vouchers are not offered. 3. loadCounterLegTopology is exercised directly: settlement ledger and twins, other-currency pocket, null/unknown ids, cache, orphan set equal to guardCounterLegs' refusals on the same fixture, lookup failure. 4. guardBookedCounterLines docstring and the /book route comment now state that only the two-cash-legs shape is inspected; a single hand-typed 19xx line is not (covering it would cost a cash_accounts lookup on every ordinary booking). DECISIONS.md lines amended accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-4 review findings (#1643) 1. Same-connection re-registration twins (the dominant prod shape): two enabled rows on one active connection sharing (IBAN, currency) are now told apart by balance_updated_at; only the most recently synced row is live, the other is a stale twin (orphaned as a counter, never a re-point destination, and the transfer detector pairs with the syncing row alone). Rows with no stamp or the same stamp both stay live. 2. POST /book: a single 19xx line that is a sibling ledger the row should move to (the live twin of a stranded row) re-points cash_account_id in the same locked UPDATE that links the voucher, mirroring manualLink. guardBookedCounterLines returns { refusedLedger, repointCashAccountId }; an ordinary booking pays one PK read of the own row. 3. manualLink refuses the link (success:false, Swedish error) when the voucher sits only on a dead sibling instead of writing a cross-account link with a server-side warn; the REST and MCP link callers reach it without the unmatched-entries filter. 4. manualLink judges a voucher touching several sibling ledgers on the best of them (a live sibling, else the first the row may move to) instead of the first line PostgREST returns. Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route; the two DECISIONS.md lines for #1643 amended in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-5 review findings (#1643) 1/2/5. Same-connection twin liveness no longer ranks on cash_accounts.balance_updated_at (a connect-time snapshot the sync never refreshes, inverted on prod in 4 of 5 stamped groups). The live row is the one whose external_uid the bank still lists in bank_connections.accounts_data (rewritten on every sync); no listing, both listed or neither listed keeps both rows live (round-3 behavior). getConnectionStatuses selects accounts_data in the same query. 3. guardBookedCounterLines single-19xx-line shape: a twin the row may not move to (dead or disabled) is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only bank leg on the dead ledger; an unrelated 19xx line still posts as typed. Route test added. 4. Disabled cash_accounts rows are never siblings, so neither manualLink nor /book re-points a transaction onto a deselected row; a voucher booked only there is refused as a cross-account link. 6. PR body rewritten to the final rules; DECISIONS.md round-4 line amended (signal correction, /book refusal, disabled siblings). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643) CashAccount.external_uid is nullable in the shared type; the same-connection twin rule now skips null uids instead of passing them to Set.has, which failed the strict type check in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643) Two enabled rows on one active bank connection sharing (IBAN, currency) are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at and round 5 on external_uid presence in bank_connections.accounts_data; each was verified against prod and each was contradicted by it (ingest routes by the accounts_data entry's ledger_account, which in two groups points at the OLD row, so the "stale" row is the one still being fed). Restores the round-3 behavior: neither twin is orphaned, the transfer finder returns null when both survive, no guard refuses either, and shouldRepointToSibling treats both as live siblings. No replacement signal; how to model the shape is a founder decision (PR #2010 review). getConnectionStatuses no longer selects accounts_data. 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> |
||
|
|
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> |
||
|
|
a1cafe495f |
fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) (#2014)
* fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) PDF extraction read only part of well-structured PDFs, two confirmed mechanisms (21-day prod window: 49 sliced docs, 29 silent empties): - The auto-extract page budget was 3 (Bedrock-latency legacy, issue #553) and the slice kept only the first pages, so multi-page invoices lost the final page where totals, OCR and 'Att betala' sit. The budget is now 8 on pdf-native backends (Claude reads PDFs directly); the slice always keeps the last page. Rasterizing self-host backends keep the old budget of 3. - A max_tokens-truncated model answer was parsed as-is, failed, and became an all-null extraction with no trace. extractFromDocument now reports stop_reason max_tokens / finish_reason length as truncated; the extractor retries once at double AI_EXTRACTION_MAX_TOKENS and logs ai_extraction_truncated either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): sweep cutoff covers the slower two-call extraction Skeptic finding on #2014: the crash-recovery sweep flipped 'processing' rows to an empty skeleton after 2 minutes, but a deferred extraction can now legitimately run 3-5 minutes (8 native pages plus one truncation retry at a doubled token cap), so the sweep stole the row and the CAS discarded the worker's real result. Cutoff raised to 10 minutes. Also: pages_partial_note made period-agnostic (old rows were extracted from first-pages-only slices, so naming the last page was retroactively wrong for them), and two stale first-pages-only comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): keep the first extraction response when the retry throws CodeRabbit finding on #2014: a throttled/failed retry call bubbled to the outer catch before rawText was assigned, discarding a first response whose text may parse fine despite the truncation flag. The retry is now caught locally (logged as ai_extraction_retry_failed) and the first result flows on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e8aa0670ca |
feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary
Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).
- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
(draft gate, roundOre, 0 = nollkorning, display-line refresh); the
cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
budget at zero headroom), op type set_run_salary (medium risk),
commitSetRunSalary executor, payroll:write scope, payroll_month
loadout + payroll-monthly skill step; update_payslip_line description
now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
snapshot updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* fix(salary): harden set_run_salary per skeptic + CI findings
- Clear calculation_breakdown when the per-run salary changes so the
existing book preflights force a recalculation: a run can no longer
be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
v1 body schema: closes the unbounded/1e307-overflow path that wrote
Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
WRITE is uncallable on Claude.ai (update_customer lesson) while three
surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
committed; matches pre-refactor route behavior) and DB error details
carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore(migrations): rename set_run_salary pair past main's newest versions
origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore: retrigger Supabase preview after migration-version repair
The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
---------
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> |
||
|
|
33a58bec51 |
fix(webshop-orders): shared effective-rate helper and order-context refusal for the rate-0 slot (#1912) (#2008)
* fix(webshop): share rate classification and check rate-0 order context in bulk book (#1912) The bulk revenue template's guard copied fetchDynamicVatAccounts' effective-rate precedence (explicit momssats > treatment > class-3 number+name inference), so the two could drift. Both now call one exported helper, resolveEffectiveVatRate, and a sibling resolveRevenueVatBox resolves the momsdeklaration box for a revenue account (treatment ruta first, then the static BAS map). The rate-0 slot also ignored order context: a domestic 0% order could be routed to an export account (ruta 36) and vice versa, misstating rutor 35-42 with no VAT amount to catch it. The sweep now refuses, per order, a 0% bucket whose billing country contradicts the chosen account's box: ruta 36 vs SE or an EU country, ruta 40 vs SE, ruta 35/38/39 vs SE or a non-EU country. Unknown country (Shopify), domestic boxes (42/41/07) and unclassified accounts are unchanged; the domestic-account + foreign- country direction stays advisory in the dialog. Item 1 of the issue (require a positive momsfri/export/EU classification for the slot) is deferred: most such accounts are unconfigured today and the strict rule needs a configure path first (DECISIONS.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(webshop): address review findings (#1912) - Finding 1: the rate-0 context guard keys on customer_country, which the WooCommerce sync stores from the billing address; the goods boxes 35/36/38 follow the delivery destination, so a Swedish-billed order shipped outside the EU is a legitimate ruta 36 export the sweep refuses. Soften the WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH copy (sv/en) to say the check is based on the billing country and the account may still be right for the delivery address, and ask the user to confirm rather than change the account. Document the limitation in the route comment; storing shipping country in the sync is a follow-up. Test pins the new wording. 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> |
||
|
|
ca93ef3fb6 |
fix(salary): surface employee-save failures and a typed 503 for the missing encryption key (#1996) (#2009)
* fix(salary): surface employee-save failures in the dialog and type the missing encryption key (#1996) Pressing Spara in "Ny anställd" could fail without any feedback: a thrown fetch or a non-JSON 5xx body escaped handleSubmit before setSaving(false) ran, leaving the button stuck on "Sparar..." and the dialog silent. Even when the toast did fire, the Radix modal aria-hides the root-layout Toaster, so assistive tech (and the E2E driver that found this) heard nothing, and the requestId support needs was never shown anywhere. - NewEmployeeDialog: fetch + parse run in a never-throwing helper, saving is released in finally, the body is parsed with json().catch(() => null) so an HTML/plain-text error page still maps through the HTTP-status map, and the failure is rendered inline (role="alert" in the footer) with "Ärende-id: <requestId>" next to the single destructive toast. - personnummer.ts: the production "key missing" throw now carries the registry code PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED, and the SALARY registry gains a 503 entry naming PERSONNUMMER_ENCRYPTION_KEY with a "contact support" message and a remediation hint. withRouteContext emits the typed envelope automatically instead of INTERNAL_ERROR 500, which read as transient and invited retries that can never succeed. - Tests for the route (401, 400, 503 with requestId and no insert), the key guard, the registry entry, errorResponse dispatch on a coded Error, and getErrorMessage locale handling of the new envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(salary): address review findings (#1996) - NewEmployeeDialog: fall back to the X-Request-Id response header when the body carries no error.requestId. The route hand-builds its 409 (duplicate personnummer) and generic insert-failure 500 bodies as flat strings, so the inline "Ärende-id" line was hidden for exactly the DB-failure class the issue names; withRouteContext sets the header on every response. - Route tests pin that the 409 and 500 insert-error arms carry X-Request-Id. 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> |
||
|
|
89ce837947 |
fix(arsredovisning): losses lost their minus sign in the PDF (#2013)
* fix(arsredovisning): losses lost their minus sign in the PDF
toLocaleString('sv-SE') formats negatives with U+2212 MINUS SIGN, which
react-pdf's built-in Helvetica (WinAnsi encoding) has no glyph for, so the
sign was silently dropped: a loss rendered as a profit on 'Arets resultat'
and 'Summa fritt eget kapital' while all totals stayed correct. Reported by
a user whose -4 684,24 kr loss displayed as +4 684 kr.
Add formatPdfKronor (absolute value + ASCII hyphen-minus) and route the K2
and K3 PDF templates and the PDF-bound note builders through it. Display
only; no ledger or iXBRL changes (the iXBRL writer already handles signs
via the sign attribute and its own presentational minus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CBPBCdBr5P75MQCt4qcnE
* fix(arsredovisning): pin PDF group separator to U+00A0 across ICU versions
Newer CLDR data groups sv-SE thousands with U+202F NARROW NO-BREAK SPACE,
which WinAnsi Helvetica also lacks: digits would silently run together the
same way the minus sign was dropped. Normalize the separator to U+00A0 in
formatPdfKronor so the rendering does not depend on the runtime's ICU.
Addresses the PR Agent reviewer-guide finding on #2013.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CBPBCdBr5P75MQCt4qcnE
---------
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> |
||
|
|
52e99295de |
fix(white-label): accept byrå-team invites before landing, so admins reach /clients (#2002)
A newly-invited byrå admin/member who signed up with email+password landed on /onboarding instead of the cockpit. Root cause: team-invite acceptance lived only in POST /api/team/accept, which the email-confirmation signup flow never reaches before the dashboard (no session for the register page's client-side accept), while the auth callback and the onboarding/select-company recovery only understood company_invitations. So the invitee's byrå membership did not exist when landing resolved, and they were funneled into creating a company. - New shared helper acceptPendingTeamInviteByToken (lib/company/pending-invites) is the single server-side implementation of team-invite acceptance. - POST /api/team/accept delegates to it; HTTP contract unchanged. - /auth/callback accepts a team invite BEFORE the silent-team check and before resolveLandingDestination runs, so an owner/admin resolves to /clients; the invite cookie is cleared on success, kept otherwise for the retry. - acceptPendingInviteByToken (onboarding/select-company recovery) tries the company path, then falls back to the team helper. - hasPendingInviteForEmail checks both invite tables, so a tokenless byrå invitee is not misread as a first-timer. No migration (team invite tables already exist). Company-invite and non-invite flows are untouched. Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17caf9d80a |
feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview gnubok_update_invoice items are a FULL REPLACE, had no article fields, and no MCP tool returned invoice lines, so a quantity fix rebuilt from memory wrote article_id/revenue_account null and reverted vat_rate to the customer default: revenue silently moved from the article account (3041) to the VAT-derived default, invisible in the approval preview. - gnubok_get_invoice (invoices:read, search-only): header plus every line with article_id, revenue_account, vat_rate, dimensions, editable_draft - gnubok_update_invoice lines accept article_id with the same prefill and default-set VAT adoption guard as create; permitted-set VAT gate at staging; preview carries the new lines' effective booking and a snapshot of the lines being replaced - commitUpdateInvoice scope-checks staged article ids like create does - OperationPreview: update_invoice preview (current vs new lines, header diffs, totals); create_invoice lines show VAT rate and posting account - invoicing skill points at the read-before-replace round trip Closes #1642 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): use roundOre for update-invoice preview totals so the ore ratchet stays at baseline The preview-building code in gnubok_update_invoice introduced five naive Math.round(x * 100) / 100 occurrences, tripping check:guards (naive-ore-round 627 vs baseline 622) and failing Core Build on PR #1993. roundOre from @/lib/money is the sanctioned helper and was already imported in this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): make the invoice round trip lossless for text, ROT/RUT and accrual lines Skeptic review of #1993 found three round-trip breaks for web-created drafts edited via MCP (the exact silent-loss class issue #1642 reports): - Text rows: the update pre-gate and resolveInvoiceLineFromArticle rejected quantity <= 0 before looking at line_type, so any draft with a free-text spacer row could not be edited at all, and the natural agent recovery (drop the row and retry the FULL REPLACE) silently deleted invoice content. Text rows are now exempt from the quantity/description/unit/price gates (CreateInvoiceItemSchema parity), normalized to the zeroed stored shape, excluded from the staged totals and the VAT gate (commitCreateInvoice billableItems parity), and line_type is declared on both the create and update item schemas. - ROT/RUT: gnubok_get_invoice omitted housing_designation, apartment_number and brf_org_number, so an items replace on a ROT draft either failed AFTER approval ('Fastighetsbeteckning krävs för ROT-avdrag') or, for a schema-conformant agent, silently stripped the avdrag and the stored personnummer. The three property columns (property identifiers, never the personnummer ciphertext) are now returned per line, the deduction fields are declared on the update item schema, deduction_type rides on the current_items snapshot and the new-lines preview, and a staging-time completeness gate (arbetstyp/timmar via validateDeductionLines, fastighetsbeteckning for ROT, personnummer availability on the invoice or the individual's kundkort) surfaces the failure to the agent instead of the approver. - Declared-schema gap: revenue_account and the accrual fields were accepted on pass-through but undeclared, so a schema-conformant agent dropped a manual posting-account override or a periodisering on pass-back. They are now declared on the update item schema (revenue_account also on create; create deliberately does NOT declare deduction/accrual fields because commitCreateInvoice drops them), and the approval preview shows ROT/RUT-avdrag and the periodisering period per line. tools/list ceiling check after the two new create-schema properties: 63337 of 63400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> |
||
|
|
f0af4ad4ee |
fix(transactions): categorize fails closed when the verifikat cannot be created (#1990)
* fix(transactions): categorize fails closed when the verifikat cannot be created (#1947) Booking into a locked period refused the verifikat but still wrote is_business/category, so the row left "Att bokföra" and the nav badge while journal_entry_id stayed NULL (canonical worklist predicate: is_business IS NULL). The verifikat is the booking: when it cannot be created nothing is written and the request returns a typed 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED (Swedish reason preserved, details.cause = underlying code); a null engine return maps to 400 NO_OPEN_PERIOD_FOR_DATE. Same shape on the dashboard route, the v1 single route and per item in v1 batch-categorize. journal_entry_error stays in the 200 body, always null, for client compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(transactions): fail closed on the engine's null return in the MCP/bulk door too Review findings on #1990: categorizeMatchedTransaction (pending-op approval, Underlag bulk-book) still wrote is_business/category with journal_entry_id NULL when createTransactionJournalEntry returned null (closed year or missing period return null without throwing), recreating the exact #1947 stranding while the tool reported success. The core now refuses before the transactions update with a structured 400 whose errorCode (PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE, told apart via checkPeriodLock) flows into result_data.error_code; the bulk driver skips such items with reason no_open_period. The dashboard route's null guard gets the same disambiguation: a closed covering year answers PERIOD_LOCKED (reason period_is_closed) instead of claiming the rakenskapsar does not exist, and the thrown-error branch now pairs messageSv with messageEn per the errorResponseFromCode contract. TX_CATEGORIZE_JOURNAL_ENTRY_FAILED message_en no longer embeds API-doc prose (details.cause guidance lives in remediation). DECISIONS line corrected: the MCP door was fail-closed only for thrown engine errors, not the null return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> |
||
|
|
cb9ae15d46 |
fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora reverseEntry() unlinked bank transactions from the reversed entry by clearing only journal_entry_id. The worklist's "unbooked" predicate is is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so the row stayed "handled": absent from Att bokfora and from the nav badge, while the storno dialog (reverse_warning) promised the opposite (#1950). The engine now resets the same triple the uncategorize paths write (journal_entry_id, is_business, category) plus reconciliation_method, scoped to rows linked to the reversed entry. Fixed in the engine so the dashboard, v1 and MCP reverse doors all agree. Closes #1950 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows through transaction_voucher_links only (journal_entry_id stays NULL), so the reset matched nothing there: all rows kept is_business = true against a status='reversed' entry, stayed out of Att bokfora and the nav badge, and is_transaction_booked() still reported them booked. The N=1 variant left a dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and kept the reconciliation bridge bucketing the row as matched. reverseEntry now deletes the reversed entry's junction rows (the same removal koppla-bort performs) and releases is_business, category and reconciliation_method only for rows left with no anchor: a remaining-links read plus journal_entry_id IS NULL guards residual bookings (main verifikat in journal_entry_id, junction row to the residual verifikat) and multi-allocated rows so stornoing one voucher never unbooks a still-booked row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink The widened reverseEntry reset (#1950) now nulls is_business, category and reconciliation_method together with journal_entry_id on the linked transaction, but the fix-cash-mismatch remediation relinked with only journal_entry_id. The repaired row ended up booked (pointer at the posted clearing entry) yet visible in Att bokfora and the nav badge (worklist predicate: is_business IS NULL), the inverted #1950 symptom; booking it from the list would conflict-storno the correct clearing entry and corrupt the AR chain the route just repaired. The relink now restores the full booked triple, mirroring the match-invoice route's final update. New route tests cover auth 401, validation 400, the no-targets path, and assert both relink payloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> |
||
|
|
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> |
||
|
|
4f939ebb21 |
fix(payroll): expose jämkning percentage and validity on the employee tax form (#1988)
* fix(payroll): expose jämkning percentage and validity on the employee tax form (#1913) An employee with a Skatteverket jämkning decision could not have the adjusted withholding percentage set anywhere in the app: model, API and engine supported jamkning_percentage / jamkning_valid_from / jamkning_valid_to end to end, but EmployeeTaxCard never exposed them. - EmployeeTaxCard: percentage input plus required from/to dates in the A-skatt branch; null (= clear the beslut) when emptied or when no table applies, mirroring tax_table_number. Both dates are required because isJamkningValid only applies a beslut when both are set. - Edit page: PATCH body sends the three fields as explicit values (guarded on the card having reported), card initial seeded from the employee, read-only Jämkning row in the tax section. - NewEmployeeDialog: initial tax state and POST body carry the fields. - Legacy PATCH /api/salary/employees/[id]: merged-state jämkning check (start date required, dates ordered), same rule and messages as v1 and employee-commands, gated on the PATCH touching a jamkning key. - lib/api/schemas.ts: truthful comment on the engine's both-dates gate. - i18n: salary_employee.tax_jamkning_* in sv and en. - Tests on the legacy PATCH route (400 x4, 200 x3) and the POST route. The engine is deliberately untouched; the API/MCP contract (valid_to optional) stays as is, follow-up filed in the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(payroll): jämkning keys reach the employee PATCH only when visible and edited (#1913) Review findings on #1988: the card reported null for the three jämkning fields whenever its inputs were hidden (sidoinkomst, F-skatt, FA-skatt, ej verifierad) and the edit page forwarded those nulls, so toggling sidoinkomst or fixing a phone number on an FA-skatt employee silently wiped a stored beslut (which the engine still applies for FA-skatt). The two date inputs were also natively required whenever a percentage was present, so a beslut stored via the API/MCP without valid_to (allowed by the schema) blocked the whole form on unrelated edits. - lib/salary/jamkning-patch.ts (new): isJamkningEditable() and jamkningPatch(); the keys are spread into the PATCH body with explicit values (null = clear) only when the inputs were visible and edited, otherwise omitted like every other sparse field. - EmployeeTaxCard: jamkning_touched flag on EmployeeTaxValue, set by the three handlers; required on both dates gated on it; non-blocking hint (tax_jamkning_incomplete_hint, sv + en) on a seeded beslut missing a date. - Edit page spreads jamkningPatch(tax); NewEmployeeDialog initial state carries the flag. - Tests: lib/salary/__tests__/jamkning-patch.test.ts (keys omitted for sidoinkomst / f_skatt / fa_skatt / not_verified / untouched seeded row, explicit nulls when cleared, spread shape). - DECISIONS.md: one line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * test(payroll): type the insert mock's payload so the typecheck ratchet accepts the jamkning tests vi.fn(() => ...) infers an empty parameter tuple, so insert.mock.calls[0][0] failed TS2493 under the new check:types gate (#1980) on CI. Declaring the payload parameter keeps the assertions and makes the tuple indexable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> |
||
|
|
175bb8bd92 |
fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business side regardless of sign, so a learned line_pattern whose ratios over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02) produced an entry off by 2x|diff|. commit_journal_entry rejected it, so the user saw a failed confirm and, since #1894, an unbalanced prefill. The 3740 leg now lands on the business side for a positive diff (under-allocation, unchanged) and on the opposite side for a negative diff (over-allocation), flipped after the mirror. computeProposalLines gets the identical rule in the same change to keep the byte-parity contract, and a 5000-amount sweep test pins engine and proposal together. Also reachable with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02. Closes #1898 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> |
||
|
|
fca57dc470 |
fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes (#1998)
* fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes The period picker re-seeded from scratch on every visit: an arsmoms user whose moms_period was never set landed on a silently guessed quarterly declaration (companies without a company_settings row bypassed every gate), and a manually chosen cadence evaporated on the next visit. - Gate the view when no company_settings row exists, matching the existing "registered but no period" gate: a declaration for the wrong period type is a compliance hazard, not a convenience. - Persist the manually chosen cadence per company (localStorage, FyPicker pattern) and restore it while moms_period is unchanged; the concrete period still re-seeds to the most recently ended one, and a changed setting discards the stored cadence. - Extract the seeding decision into lib/vat/period-selection.ts with unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9 * fix(vat): drop cadence persistence; the moms_period re-seed is the control Skeptic review refuted the persistence half of the previous commit twice: the render-phase localStorage restore diverged from SSR (hydration error on every visit once a cadence was stored), and restoring a manually chosen cadence that deviates from moms_period kept the filing pipeline open on the wrong period type across visits, with no downstream path validating period type against the setting. The redovisningsperiod has exactly one lawful value per company, so the mount-time re-seed from company_settings.moms_period is the self-healing control, not a bug. The settings-row gate and the extracted, tested seeding resolver stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9 --------- 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>
|