cb9eedd7f2fd09b84da9d05ae030cd95be95333f
572
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cb9eedd7f2 |
fix(bank): unchecked accounts yield their bokföringskonto to a checked one in the picker (#2449)
Unchecking the wrong bank account and putting the right one on 1930 answered 400 "Flera bankkonton kan inte bokföras på samma konto": the collision pass counted every stored account as a claim, the picker hides the ledger dropdown for unchecked rows, and disconnect plus reconnect re-claims the same cash_accounts rows by IBAN. No route out (support case 2026-09-09, two 400s on the route in the Vercel logs). Checked accounts stay hard claims (duplicate and foreign-live = 400). Unchecked accounts hold their ledger as a soft claim: kept and mirrored with enabled=false unless a checked account wants it, then they yield and lose the prefill. Contested rows (this connection's row for a yielded or moved account, another connection's row for an account unchecked there) are demoted to manual in one update before the mirror, so upsertFromPsd2 promotes the holder in place and row ids, transaction links and the is_primary flag on the 1930 row survive. The same pass makes two checked accounts swapping ledgers work, which previously tripped the unique constraint in both upserts and was swallowed. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6ea92f3152 |
feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed). Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw. What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body. Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows. Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB |
||
|
|
500806f001 |
fix(agent): the assistant reaches earlier räkenskapsår: period resolved from dates, years listed in the grounding (#2436)
Part 3 of #2185. A user with several imported years concluded the assistant "only reads the period I am standing in". Nothing restricted it: the report tools defaulted to the most recent fiscal period when no period_id was given and then rejected any from_date/to_date/as_of_date outside it, and neither the snapshot nor the chat identity block told the model which years existed or how to address them. - extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date hint; without period_id, a date in the call resolves the fiscal period that contains it (findFiscalPeriodContaining, company-scoped), and a date no period covers fails with the company's span instead of the latest year's bounds. Income statement (from_date or to_date), balance sheet (as_of_date) and dimension P&L (to_date) use it. The range guard is unchanged. No schema change: tools/list sits at its token ceiling. - lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on addressing an earlier year, shared by the single-call snapshot and the streaming chat's always-on identity block. - lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's period_id, one call per year, and say which räkenskapsår the answer covers. Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9782f80db0 |
feat(invoices): offert to kundorder, the missing step in offert, order, faktura (#2442)
* feat(invoices): offert to kundorder, the missing step in offert, order, faktura "Skapa order" on an open or accepted quote creates a draft kundorder from its lines. The quote stays as the customer's accepted agreement (flips to quote_status accepted with a compare-and-set on the decision that was read); the order is delivered and invoiced, in full or in parts, from the kundorder page. Declined quotes are refused. Same action on the MCP side: gnubok_convert_invoice takes target 'order', staged under the existing convert_invoice operation type. Why the problem occurred: the proforma -> order conversion refused every source that was not a proforma, so the offert, which is what users actually send before an order, could only become an invoice. The product had both ends of the Fortnox flow (offert, kundorder) but no bridge. What was removed or simplified: no second service and no new operation type. The proforma conversion became the document conversion (lib/sales-orders/convert-to-sales-order.ts) with the quote source as a branch on the source update, mirroring how convertToInvoice already treats the two. The MCP surface is one tool with a target parameter rather than a sibling tool, which also gives proforma -> order the MCP surface it did not have. Why this shape: the sale must never exist twice. A quote with a live converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED), and a quote with a live kundorder cannot become an invoice a second time (new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A cancelled order or invoice frees the quote again. Rejected: cancelling the quote like the proforma path (hides the accepted agreement), a separate gnubok_convert_quote_to_order tool, and refusing expired quotes (the invoice path allows them behind a confirm; the order path does the same). Fixes #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxwavqBoG1HwFD5znkCGLv * fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate Skeptic refutations on the offert -> kundorder change: 1. An already-accepted quote could be converted twice concurrently (two orders, or an order and an invoice): the services' pre-checks are not serialized and the accepted -> accepted compare-and-set matches for every caller. Migration 20260908152555 adds a partial unique index (one live kundorder per source document) and two BEFORE triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa, so concurrent conversions queue and the second one sees the first. The services map the raised codes onto the same 409s the pre-checks use. pg-real test covers the index, both directions, reopen from cancelled, the member-session lock, and the concurrent pair on two connections. 2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a NULL exchange rate when Riksbanken had none, which resolveSekAmount() then posts 1:1 as kronor. Pre-existing, but the quote now depends on the order path and the fail-closed quote -> invoice route is refused while an order lives. The order path now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order The approval dialog's consequence sentence was keyed on operation_type alone and promised a faktura with F-number for every convert_invoice. With target 'order' the commit creates a draft kundorder and books nothing, so the sentence now reads the params (skeptic refutation). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page Correctness skeptic refutations on the offert -> kundorder change: 1. A quote with a live kundorder could still be set to open or declined (dashboard route, v1, MCP): the decision guard only knew converted invoices. The dashboard then hid the re-accept button, so the quote was stuck as "Avböjd" behind a confirmed, invoiced order. Migration 20260908155231 extends invoices_quote_decision_guard to refuse leaving accepted while a live kundorder points at the quote (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code. 2. The two source guards from 20260908152555 locked the quote row with a SELECT FOR UPDATE as the invoker. Under RLS that also applies the UPDATE policy, which admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. All three guard functions are now SECURITY DEFINER. pg-real test covers the non-active company and the decision lock. 3. The kundorder page labelled every source "Proformafaktura". It now loads the source document and shows "Offert OF-nnn" for a quote; the MCP field description and the type comment say proforma or quote. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards CI: the target parameter and two description edits pushed the projected tools/list payload to 60 502 tokens against the 60 500 ceiling; the same facts now fit in fewer words (ceiling unchanged). Superagent P2: the source guards run as definer since 20260908155231, so a source_invoice_id or converted_from_id pointing at another company's document would have locked and inspected that row. Both guards now require the source to belong to the row's company and refuse otherwise (SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH), covered by a cross-company pg-real case. Migration 20260908155231 was re-applied to staging under the same version (never on prod). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move the quote conversion guards to versions after main's 20260908164944 Main merged a later version while this branch was open; Supabase applies pending versions in order, so both files are renamed to fresh versions (20260908165000, 20260908165100) and re-tracked on staging under those. Byte-identical SQL. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b2b714f829 |
fix(invoice-inbox): make the extraction prompt fill invoiceDate on receipts (#2429)
Receipts came back without a date on 46% of items in the last 30 days (75% via WhatsApp), while supplier invoices lost it on 0.4% and the purchaseTime on the very same receipts was filled almost every time. The prompt described invoice.invoiceDate as a bare ISO date under the invoice block, right beside a purchaseTime rule marked "receipts only", and the model read the asymmetry as "invoice-only". Describe the field as the invoice date or, on a receipt, the purchase date printed on it, and add an explicit receipts rule. Pin both in the extraction test. No schema or data-shape change; already-extracted items are not touched. Claude-Session: https://claude.ai/code/session_015bTBgZrofpGCgSUfAKN2H3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
26e29f47bc |
feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1157ff1b66 |
feat(onboarding): the orgnr field also accepts a company name (#2421)
* feat(onboarding): the orgnr field also accepts a company name The journey's first question kept asking for an organisationsnummer, and people who do not know theirs by heart left to look it up. The same field now takes either: digits (with dashes or spaces) run the existing orgnr lookup unchanged; anything else with three or more characters runs a free-text name search against the same TIC index. One hit continues exactly as a typed orgnr would; several hits render as a chip row "Name / orgnr / city" inside the same question, and the pick applies the hit's already-fetched lookup result. No hits stays on the step with a note to refine or type the number. The screen, placeholder and hint are otherwise untouched; only the mobile keyboard changes from numeric to text. Why the problem occurred: the lookup was keyed on the one identifier the user is least likely to remember, while the provider index behind it is a full-text index that already answers names. What was removed or simplified: nothing new is stored. The TIC search document carries every field /lookup returns, so a name hit is mapped by the same mapper and a picked hit costs no second provider call. The reducer gained one shared "TIC answered" transition (applyLookupFound) that the typed-orgnr path, the single-hit path and the pick path all use, instead of three copies of the fact-to-settings mapping. Why this shape and not the proposed one: search-as-you-type autocomplete would burn the 3000/mo TIC budget in days, so the search fires on Enter only, like the orgnr lookup. Taking the top hit blind on several matches was rejected: name ranking is fuzzy and common names or sole-trader surnames would land on a stranger's company; a five-chip pick row is the smallest thing that keeps the user in control. The route answers 400 under three characters, 404 in the handler's own "Company not found" shape so the client's existing dispatcher-vs-handler mapping applies, and every TIC failure code maps through the same handler as /lookup. Fixes #2418 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW * fix(onboarding): reduce Lens registration numbers to the 10-digit form for name-search hits Skeptic pass on 1d70716a8 (issue #2418). A sole trader found by name got Lens's 16-digit registration number (century-prefixed personnummer plus a 4-digit serial) stored as org_number; createCompany refuses anything normalizeOrgNumber rejects, so the journey dead-ended at the last step and the returned orgnr step could only shake. The typed-orgnr path never stored Lens's number, so this was the first place it reached settings. - searchCompaniesForLookup derives orgNumber through the new lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit enskild-firma form reduce to the 10-digit key; hits that do not normalize are dropped, never dead-ended). - Sole-trader chips show "Enskild firma" and city instead of the number, which is the owner's personnummer. - The name path resets the duplicate note on submit, so an earlier orgnr's "you already have X" no longer sits above the chip row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW * fix(onboarding): keep the typed name in the field after a search pick Compliance swarm on PR #2421: writing the picked hit's org number into the visible input printed a sole trader's personnummer in plain text on Back, the one thing the chip row masks. The field now keeps the name the user typed; Back re-searches it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2303f75a7b |
fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage Why the problem occurred: the supplier register was written in three spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored whatever the caller sent, the AI extractor emits bare digits) while matchSupplierByIdentity compared raw strings with .eq(). The canonical rule existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey, the extractor's toOrg10) and nowhere on the path that decides a match, so every AI-extracted invoice from a hyphen-registered supplier missed the strongest key and fell to exact-name matching. Prod holds 1738 hyphenated rows against 493 bare ones. What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10 of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two other copies. The matcher scans the company's suppliers with an org_number and compares keys, the same shape as its vat_number branch, so rows written before the backfill (and self-hosted instances that never run it) match too. CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier schema store the key; the form renders it through formatOrgNumberDisplay. A backfill migration strips the formatting from existing rows, skipping migration-reset source companies. Why this and not the proposed one: the issue's third layer (CHECK plus a unique index) would fail to create on prod, which holds 94 duplicate (company_id, key) groups across 18 companies, one of them 124 rows under a single placeholder-looking number; that needs a merge decision first and is filed as #2404. Rejecting anything that is not 10 or 12 digits on write was also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL, FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is canonicalised and anything else is stored as typed. Luhn stays lenient on suppliers because two rows with the same mistyped number are one supplier and parties is Luhn-strict at promotion already. Fixes #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key Skeptic pass on the previous commit. Three refutations, all confirmed: 1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to 6012579001, another company's identity, on every write path and in the backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A Belgian BE0123456789 lost its country letters the same way. The key now strips only hyphens and spaces and unprefixes 12 digits only behind 16/18/19/20; everything else is null, stored and compared as typed. The migration carries the same rule. 2. The supplier list search, the v1 ?search= filter and the list column all used the raw stored value, so a user searching 556677-88 after the backfill found nothing. Both searches now compare without separators and the column renders XXXXXX-XXXX. 3. Storage was not canonical on every path: the CSV import and the provider migration orchestrator wrote as typed and keyed their re-sync dedup by the raw value, so a Fortnox re-sync sending 556677-8899 would have duplicated the now-bare row. Both write and key through orgNumberKey. Also: the matcher scans live suppliers only, so a register holding an archived hyphenated row next to its live replacement resolves to the live one instead of whichever id sorts first. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical CodeRabbit findings on PR #2405, all verified against the code: - The supplier list rendered through formatOrgNumber, which strips letters and would show BE0123456789 as 012345-6789; it now uses formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone. - The CSV import dedup fell back to digits-only, so BE0123456789 and FR0123456789 collided; the fallback is now the value as typed, in both the parse preview and the execute route. - The provider migration's supplier-invoice stub map was keyed by the raw provider value while the stored row was canonical, so 556677-8899 and 5566778899 on two invoices produced two stubs; the key goes through orgMapKey like the other maps. - v1 response examples show the stored 10-digit form; the request example keeps the hyphenated input. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * docs(api-skill): regenerate suppliers reference for the canonical org_number example Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
477b59453f |
fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string (#2398)
* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string
Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").
One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card
Skeptic findings on 4a30bb3f3:
- The repair migration would have aborted on prod: 110 of the 6,356 rows
belong to a migration-reset source company, whose transactions are
immutable by trigger (transactions_block_migration_reset_source_mutation).
Same failure as 20260903170000. Those rows are now excluded; nothing reads
the column back for an archived company.
- With the code description reaching the keyword tables as a string,
"Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
precedes withdrawal in both tables (and in the Connect mirror), matching
what TRAILING_PHRASES already says about the same phrase.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed
coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
fdcb7d937e |
feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
57a5af1310 |
fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on (#2386)
* fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on Sessions are per domain. A white-label user who started a WooCommerce connect on their brand domain was sent back by the store to the canonical app URL, where the return leg's initiator check found no session and bounced them to a foreign-branded login. The connect route now resolves the request host through the trusted-origin helper (brands-table validated, canonical on an unknown host or a failed lookup) and builds the wc-auth return_url on that origin; the callback_url stays on the canonical host because it is server-to-server and needs a stable address. The return route resolves its panel redirect base from the host it was reached on the same way. No stored origin column and no OTC handoff: the wc-auth return_url is free-form per handshake, unlike a registered OAuth redirect URI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * test(woocommerce): name the state-less return test for what it asserts, drop the dead app-url stub The return route now resolves its redirect base through the trusted-origin helper, so a brand-host hit can do one cached brands lookup; the test only ever asserted that woocommerce_connections is never touched, and now says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f047c3d7d1 |
fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user (#2373)
* fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user The Skatteverket OAuth callback answered NEXT_PUBLIC_APP_URL regardless of where the flow started, so on a white-label brand domain the popup's postMessage was dropped and the fallback redirect landed on the wrong origin without a session. On hosted, the initiator check from #2155 was bypassed by design because the registered callback host carries no app cookies, so a lured victim's BankID-authorised tokens could be stored under the user who started the flow. Flow state moves from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000), consumed atomically. Hop 1 on the registered OAuth host consumes the state, stashes the provider code or error encrypted under a separate handoff id and 302s to the recorded origin; hop 2 there claims the handoff bound to that origin, requires the initiating user's session, re-checks membership and exchanges the code. Error pages keep the tab open. The self-hosted single-hop and the connector broker branch keep working. The hosted no-session exception, the legacy cookie-user fallback and the optional PKCE verifier are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): decide the callback hop by host, close the tab when the flow is unknown Skeptic findings on #2373. The hop comparison and the handoff claim used the request origin including its scheme, which Next derives from x-forwarded-proto; a self-hosted proxy that forwards Host without it (or rewrites Host to the upstream address) made every connect end in a state error. Hops are now compared by host only, and the handoff is claimed for the validated origin the host resolves to, scheme from configuration. Error pages answered before the flow row is known (unknown, expired or replayed state or handoff) post to a guessed origin that a brand opener never hears; they now close the tab so the panels' closed-tab watcher resets them instead of leaving Connect disabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * test(skatteverket): mock resolveBrandResultByHost for the merged login-redirect resolver Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): bind the initiator before the flow is spent Superagent P2 on #2373: hop 2 deleted the handoff before the session and membership checks, so a signed-out or wrong-user arrival burned a live consent. The finishing hop now peeks the row for its initiator, binds the completing session to it, and only then consumes atomically. A session-less arrival is sent to /login on the initiating origin and resumes into the same callback URL; a different user is refused with the row left claimable for the initiator. The handoff TTL is five minutes so a sign-in fits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): check membership before the flow is spent, answer the callback page on a failed mint Second review cycle on #2373. Superagent: the company-membership check ran after the consume, so a revoked initiator burned the provider code on the way to being refused; it now runs inside the pre-consume binding. CodeRabbit: a failed handoff mint escaped as a framework error page the opener never hears; it now answers the callback error page on the initiating origin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cb962fae88 |
fix(auth): resolve BankID confirmation and email-hook link hosts through the trusted-origin registry (#2380)
* fix(auth): resolve BankID confirmation and email-hook link hosts through the trusted-origin registry The BankID confirmation mail built its /auth/callback link from the raw forwarded host and protocol; it is the one auth link GoTrue's redirect allowlist never sees, since the link is minted here and sent through Resend. The Send Email hook followed GoTrue's redirect_to verbatim: the webhook signature proves who sent the payload, not that every destination in it should be followed, and the GoTrue allowlist is a hand-configured glob. Both now resolve the destination through lib/domains/trusted-app-origin like every other auth link (canonical, this deployment's own Vercel hosts, or a registered brands.domain). Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is taken from the RESOLVED host, so mail branding and link destination always agree. A brands-table read failure refuses instead of mailing a wrong-host link: the BankID helper returns step resolve_origin (signup rolls back, login re-send logs), the hook answers 500 so Supabase retries. Drops the proto parameter from the BankID helper; the resolver owns the scheme. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T * fix(auth): read the sender brand once, failure-aware, before minting or sending auth mail CodeRabbit: resolveTrustedAppOrigin could classify a brand host, then the separate resolveBrandByHost read for the sender could fail and return null, so a brand link went out with the platform sender; the BankID helper had already minted the magic link by then. Both sites now read the brand with resolveBrandResultByHost on the resolved host and refuse on a failed read for any non-canonical origin (BankID: step resolve_origin before generateLink; hook: 500 so Supabase retries). On the canonical origin a failed read is the platform sender either way, so mail still goes out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T * fix(auth): treat a credential-bearing redirect_to as untrusted in the email hook Superagent P2: URL.origin drops userinfo, so a redirect_to with credentials on a served host passed the origin comparison and was cloned into the auth link with the credentials still in it. No flow of ours sends one; the hook now rejects any redirect_to carrying username or password outright and links to the canonical /auth/callback with no next path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e0244b95a7 |
fix(woocommerce): require both verified keys and browser confirmation to activate a connection (#2375)
* fix(woocommerce): require both verified keys and browser confirmation to activate a connection The wc-auth callback alone used to flip a connection to active. Until the initiator's browser reached the return route the row was syncable, so an approver who never came back (closed tab, skipped sign-in, or lured into approving a connect someone else started) left their store's keys active inside another company's books, reachable by manual sync within seconds. Now the callback only stages the verified keys on the pending row, the return leg records browser_confirmed_at after the initiator check, and one conditional update flips the row to active exactly once when both signals are present, in either arrival order. A DB CHECK (20260907100000) makes an active row without both signals impossible; every consumer selects status = 'active', so staged keys can never sync. Also: 15-minute handshake TTL on both legs, duplicate callback refused, stale pending rows swept (keys wiped) at the start of the nightly orders cron, manual key entry records the confirmation itself, every path that closes a pending row wipes staged keys, expired/conflict toasts in sv + en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): drop the callback-leg TTL and state the gate's real scope Skeptic pass on the activation gate: - WooCommerce answers any non-200 callback response by deleting the key it just minted and showing a store-side error page, never redirecting back. A 410 for a slow approval therefore stranded the merchant. The callback now stages regardless of age; the session-bound return leg and the nightly sweep enforce expiry, and a stale pending row cannot sync either way. - The second activation signal comes from the initiating user, so the gate does not stop a store admin from approving a link someone else generated (wc-auth delivers keys server-to-server and identifies no approver). The migration header, route comments, decision log and PR body now say so instead of claiming otherwise. Proof of store control is a follow-up. - Every path that parks a pending row also wipes the store metadata the probe staged, so a refused handshake leaves nothing of the store behind. - A duplicate callback POST is a 200 no-op instead of a 409, so the status code no longer tells the state holder whether the merchant has approved. - The expiry message tells the user to remove the unused key in the store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial Re-verification of the skeptic fixes found two gaps: - The initiator can open the return URL early, so a row confirmed at minute one still flipped when the keys landed hours later; the callback no longer refuses stale rows, so nothing bounded that. The conditional activation update now also requires created_at within the TTL. The callback still answers 200 (no store-side wp_die); the flip matches zero rows and the sweep parks the row. Covered by a pg-real case with both signals present on a 20-minute-old row. - The store-denied path parked the row without clearing the staged store metadata. It now wipes the same five columns as every other parking path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep Review round on PR #2375: - Superagent P1: browser_confirmed_at was member-writable through the row-scoped RLS policy, so any writer of the company could supply the initiator's signal on a colleague's pending row. New migration 20260907150000 adds a trigger keyed on the JWT role claim (same pattern as enforce_company_writer_role): end-user sessions cannot insert or update the column, service role and migrations pass. Manual key entry now inserts on the service client with company_id/user_id from the verified context. pg-real covers refusal on insert and update plus the server path. - CodeRabbit: a replayed callback for an already-keyed row now runs the activation flip instead of returning early, so a callback cut off between staging and activating is completed by its retry. - CodeRabbit: the cron deadline is fixed before the stale-handshake sweep, so the sweep counts against the route's maxDuration budget. - CodeRabbit: the activation CHECK is added NOT VALID (rows were already conformed by the backfill) and validated in 20260907150000 under the weaker lock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make activation itself server-only, install the trigger before validating the gate Second review round on PR #2375: - CodeRabbit: an authenticated company member could still UPDATE ... SET status = 'active' on a fully staged row through PostgREST; the CHECK only proves both signals exist, and the 15-minute TTL lives in the server's conditional activation update. The server-only trigger now also refuses any end-user transition into 'active' (insert or update). Leaving 'active' (disconnect, supersede, revoked-key marking) stays member-writable. pg-real covers an expired, fully staged row: 42501 from a user session, then a member disconnect after the server activates. - Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is installed, so the gate is never enforced while its signal is still member-writable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a769bc9d03 |
feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow BL issued our integration activation key on 2026-09-07. With BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i Lundify": the customer logs in at Lundify, picks the company and accepts the scopes, and Lundify returns the company's User-Key to our callback as publicKey with our one-time state echoed as extra. The manual User-Key field stays as a folded fallback for companies that activated inside Lundify already. The callback folds publicKey/extra into the OAuth-shaped locals, so the atomic state consumption, initiator binding and white-label handoff run unchanged; only the final step differs: submitProviderToken (the same client-credentials probe as the manual field) instead of an OAuth code exchange, owned by the consent's company read from the server-written row. consumeOAuthState/consumeHandoff now return that company id. Closes #2323. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L * fix(migration): reset the previous connect attempt before a new provider request Review follow-up: a failed /connect used to leave the earlier consent id and one-time activation URL in place, so the step kept offering a link that completed the previous consent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d29a5bda14 |
fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS Password reset, invite, email change and signup links now resolve the request host against brands.domain server-side. The env var was a second copy of that registry compiled into the browser; every new brand needed the row, the env var, the GoTrue allowlist and a redeploy, and two partners shipped with the env var stale, so their reset mails went out canonical-branded to the canonical host. - New POST /api/auth/password-reset: the login page no longer calls GoTrue directly, so the browser carries no domain list. - lib/domains/trusted-app-origin.ts is async and registry-backed; it also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so previews keep sending links to themselves. - Signup shares the same resolver instead of following the raw host. - Docs and .env.example describe the single registry; GoTrue keeps the redirect allowlist as backstop (hosted: *.accounted.se wildcard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): await the async origin resolver in the billing routes merged from main PR #2370 added resolveRequestAppOrigin callers in billing/checkout and billing/portal after this branch made the resolver async. Await them and move their tests from the removed env var to the brands mock; update the login source-assert test to the server-routed reset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs Skeptic and CI findings on #2376, one pass: - A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR, 503, retryable) instead of falling back to the canonical origin: a canonical link is the wrong-brand mail this PR removes. Password reset and email change answer 503 themselves; withRouteContext routes map the code. - A local canonical (dev) trusts other local hosts and ports on the same scheme, so lane servers on 3001-3003 confirm signups on themselves. - GoTrue matches the full redirect_to including the query and `*` stops at `.` and `/`: docs and decision line now prescribe https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**. - The Turnstile contract test asserts the server-routed reset forwards the captcha token (it still asserted the removed browser call). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c634cf9ae0 |
fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)
* fix(banking): return Enable Banking consent callbacks to the initiating brand host Enable Banking redirects every consent to the one canonical callback URL while browser sessions are per host, so a white-label user reached the callback signed out and was bounced to the unbranded canonical login. The pending row now records the allowlisted origin the flow started from, and the callback uses it for the login bounce, the success redirect and the denial banner. The brand host already holds the session, so its /login forwards straight back into the callback with cookies; the provider redirect URI stays canonical, nothing changes in the Enable Banking console. The shared login redirect helper also stops dragging a callback that arrived on a registered brand host to the canonical login. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz * fix(banking): reload the PostgREST schema cache after adding oauth_origin Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY, and without it PostgREST can reject the new column on connect until its cache refreshes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6906bc4aa2 |
feat(compliance): InvoiceRowsCompleted behandlingshistorik event for migrated invoice rows (#2312) (#2357)
Every migrated sales invoice whose rows complete_invoice_rows writes, from the migration wizard or the hourly row-completion pass, now leaves one InvoiceRowsCompleted row in processing_history on a new Invoice aggregate: the writer, the provider, the consent, the row count, and the header VAT split before and after when the pass rewrote it (BFL 5 kap 11 §, BFNAR 2013:2 p. 9.16). One run shares one correlation id. lib/invoices/complete-invoice-rows.ts is the one TypeScript call site for the RPC and the one emitter: it appends only on wrote = true, records nothing for already_filled or failed, and keeps the append best-effort (logged, eventId null) like every other processing_history writer. The wizard runs on the user's session client, so MigrationOptions takes a lazy createHistoryClient for the service role. Invoice numbers stay out of the payload (the personnummer guard would drop ten-digit ones). Migration 20260906210100 widens the aggregate_type CHECK with Invoice and registers the event type; pg test covers the catalog row, the aggregate, and that the CHECK still refuses unknown aggregates. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1a725c121a |
fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel (#2349)
* fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel Sends now say HOW they failed (failure: http_rejected | transport_error), and the company question falls back to numbered text only on an HTTP rejection. A timeout means Meta may already have delivered the interactive question, so that case rolls the question back instead of putting a second copy on the phone; the next receipt re-asks. Both drains (the company answer and the single-live-company path) share one helper that stamps rows older than Meta's ~30-day media retention as company_choice_expired instead of re-opening them into the MAX_ATTEMPTS error path, and tell the sender once (M20) how many receipts could not be recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep and the drains read one definition. POST /link/default-company checks LIVE membership with the same companies!inner(archived_at) filter intake uses, so a default pointing at an archived company is refused (403) instead of saved and then silently ignored; a failed membership read is a 500, not a 403. consumeLinkCode is tri-state: a failed lookup or claim returns 'transient_error' and the webhook answers with a neutral retry (M21) instead of M2 "the code is wrong" to a user holding a valid code. In degraded mode (quota RPC down too) M21 sits behind the same fail-closed throttle as M2. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle Review pass on #2349 (CodeRabbit, four findings, one commit): - drainParkedRows reads the error of both UPDATEs, logs each, and returns failed: true. The answer stays applied (pin set, options claimed) and the single-company path still clears the dead question: both turn the rows that are still parked into orphans, and a new sweep pass re-opens parked rows whose conversation has no open company question (older than two minutes, so a row parked just before its question is armed is left alone) through the same drain and processes them. - badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a lookup outage cannot earn one M21 per message in degraded mode. - The M20 expiry notice logs a failed send. It stays best-effort like M17, M18 and M19: the extension has no durable outbound retry, and the rows the notice describes are already terminal. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): type the orphan scan rows and the cron summary fixture reopenedOrphans joined SweepSummary in the previous commit; the cron route test's fixture and the PostgREST embed cast in the orphan pass had not followed (check:types caught both). Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7448490fb7 |
fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) The invoice-to-verifikat link is written on the invoice side only (invoices.journal_entry_id, invoice_payments.journal_entry_id), while the missing-underlag predicate and the periodisk sammanstallning resolved the invoice from the entry's own source columns. A SIE-imported sale matched to its invoice afterwards therefore kept warning "Underlag saknas" and was left out of the EU sales list, although the account-based momsdeklaration showed it and the verifikat page already listed the invoice as its underlag. - verifikat_without_documents / transactions_without_documents: customer- invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row; new migration 20260906135702, pinned by a pg-real test. - getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used by the journal-list filter and bulk exempt, /api/documents/counts (new invoice_references map) and the transactions list; the push cron mirrors it with its global reads. - Journal list: no "Underlag saknas" chip for a covered entry, matching the engine's own invoice rows and the verifikat detail page. - Periodisk sammanstallning: entries fetched by their EU-revenue lines and attributed through every link (engine source_id, invoices.journal_entry_id, invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment entries are filed too, which the old source_type filter dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review) - The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron) now require an ISSUED invoice: status not in ('draft', 'cancelled'), the schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_ STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the pg test pins a draft-linked and a cancelled-payment entry as still missing. - Periodisk sammanstallning: one verifikat linked to invoices of different customers is no longer attributed to the first invoice; it is left out of the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT naming the voucher, the customer count and the amount. Same-customer settlements are filed in full. - Transactions list: a failed invoice-reference lookup leaves that chunk's verdict unknown (no badges) and continues with the remaining chunks instead of abandoning them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cce0de5704 |
feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when posted, unlinked vouchers already summed to the bank row (PR #2300), but the MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate) called the RPC with no guard, so an agent could book a Bankgirot aggregate a second time. The detector existed once; the guard lived in one door. One shared decision helper, lib/invoices/already-explained-guard.ts, now sits on top of the existing detectors (no fork) and is called by the dashboard route, the MCP staging tools and the commit executors: - gnubok_match_batch_allocate refuses to stage, coded BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match / link_transaction_to_journal_entry call that resolves the row, and the exact force + expected_journal_entry_ids binding. - commitMatchBatchAllocate runs the same guard before the RPC and re-validates a staged force binding against the set detected at commit, so a stale approval cannot book a duplicate; 409 auto-rejects with the vouchers in result_data. - force + expected_journal_entry_ids on the tool mirror MatchBatchSchema; an honoured override stages with a compliance_warning and, after the booking succeeds, writes BankTransactionDuplicateDismissed to behandlingshistorik (dashboard route included; it only logged before). - gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force + expected_journal_entry_id; at commit it runs before the storno. - Registry: both duplicate codes gain retryable: false and a remediation. Catalog payload held under the 60K ceiling by trimming the two tools' own descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review) Review round on PR #2346 (CodeRabbit + compliance): - guardAlreadyExplained returned 'clear' when the detector threw even with force=true, so a forced 1:N override could book without re-validating expected_journal_entry_ids and left no behandlingshistorik record. It now returns a distinct 'unverifiable' outcome under force (mirrors guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool and the commit executor all refuse it with the new registry code BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression tests on every caller. - A detector failure without force still fails open at stage time, but no longer silently: the tools track onDetectError and stage a complianceNote, so preview_data.compliance_warning is set on both match_batch_allocate (GenericPreview renders it) and match_transaction_invoice (MatchTransactionInvoicePreview now renders data.compliance_warning through AttnLine). - expected_journal_entry_ids / expected_journal_entry_id are validated at the MCP boundary (array of 1 to 10 non-empty strings / non-empty string) and refused with VALIDATION_ERROR instead of being silently filtered. No schema description text added: catalog payload unchanged. - RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR 2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
39d409d257 |
fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them (#2313) (#2340)
* fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them The row-completion pass (#2291) and the migration wizard both wrote invoice_items for migrated sales invoices with check-then-insert across separate statements and nothing serializing them per invoice; the pass also wrote the header VAT split in a third statement, so "rows landed, header did not" was reachable and never revisited. Adds complete_invoice_rows (SECURITY DEFINER, FOR UPDATE on the invoice scoped to the company, inserts only when the invoice still has no rows, optional header split in the same transaction, returns wrote) and routes both writers through it: the pass one call per invoice (wrote = false is skipped, not completed), the wizard one call per invoice in small concurrent groups. Unknown row keys and partial headers are refused rather than dropped. Grants: revoked from PUBLIC and anon, kept for authenticated (membership gate in the body) and service_role. pg test proves the invariant (first call writes, second returns wrote = false with rows and header unchanged), the rollback of rows on a failing header, every refusal, the grants and two-connection serialization. Closes #2313 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): complete_invoice_rows requires the row's tax facts instead of defaulting vat_rate to 25 Review finding on #2340: COALESCE(r.vat_rate, 25) let a row without a rate land with a fabricated 25 % (ML 17 kap 24 § p.9). Both writers always send vat_rate, line_total, vat_amount and description, so the defaults were never needed and only hid a bug. The RPC now refuses a row missing any of the four (absent or JSON null) with MISSING_REQUIRED naming the column; sort_order, quantity, unit and line_type keep their table defaults since none states a tax fact. The rate's value is deliberately not restricted to the Swedish set: 0 (omvänd skattskyldighet, export) and foreign rates (OSS) are legitimate on a migrated row, and the pg test pins both as accepted. Migration edited in place: unshipped, preview branches only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
272d19b287 |
fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side (#2299) (#2345)
* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side The mark-paid guard probed merchant_name for the FULL supplier name, so the row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never matched and the payment was booked twice (#2299). - counterpartyNeedle(): first distinctive token of the name (alnum, legal forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on merchant_name OR description in one .or() per currency sweep; the alnum shape is what makes the DSL interpolation safe. - findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer detector; both share the sweep and the scorer. The dashboard route's inline copy is deleted; the v1 supplier mark-paid door gets the guard it lacked. - New match_reason already_booked (row already carries a verifikat, booked straight from the bank side): ranked first, carries journal_entry_id, and the dialogs, MCP path and pending-operation commit word the remedy as a rattelse rather than "link it". - Customer side gets the same token prefilter and classification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard The customer detector now issues one .or() counterparty probe per currency sweep instead of two ILIKE queries, so every queued answer after the guard was consumed one step early: the aggregate-sweep [] became company_settings, the settings row hit the entry builder, and two tests saw 500 / the wrong voucher id. Each guard block now enqueues one probe plus the aggregate sweep; the 409 tests drop the second-probe entry that is no longer read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): one logic expression per duplicate-payment sweep, never two or= params The sweep chain carried two .or() calls (currency clause, then name probe). postgrest-js appends a query parameter per call, so the client sent or= twice, and whether PostgREST ANDs a repeated key was never proven in this repo; had it kept one, the currency predicate would be gone and foreign rows banded against a kronor figure. counterpartySweepLogic() now nests both groups under one and() inside a single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*, description.ilike.*x*)). The sweep issues exactly one .or() per currency. Proof at three levels: unit tests pin the helper's string; a fake-fetch test runs the real postgrest-js builder and asserts exactly one or= search param per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit (with an amount_sek that would pass every JS check) and right-currency+miss rows against a real PostgREST and asserts, for both detectors and both sweeps, that only the first comes back, from PostgREST's own response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): name storno as the already_booked remedy, never "makulera" A posted verifikat is never deleted; it is corrected by a storno entry (BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the MCP and pending-operation messages and both UI descriptions now say so: "vänd en av verifikationerna med storno och koppla underlaget till den som blir kvar" / "reverse one of the two vouchers with a storno entry and attach the underlag to the remaining one". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
162de2128a |
fix(import): show "created on import" as the mapping target for source accounts the chart lacks (#2342)
The guided Fortnox import self-mapped a source account that exists in neither the company chart nor BAS (4599) and then rendered its Malkonto select blank, because the dropdown only knew chart + BAS accounts. The row looked unmapped and unmappable while the import created the account correctly. A nameless account (referenced by #TRANS without #KONTO) was worse: the mapper refused the self-map, so it stayed unmapped with no self-target to pick. - account-mapper: the bas_range self-map no longer requires a #KONTO name; unmapped now means exactly "outside 1000-8999". isValidBASRange exported as the auto-create boundary. - AccountMappingStep (shared by both wizards): a target the list cannot name is an explicit "<nr> <name> (skapas vid importen)" option, a "nya konton skapas" badge/filter lists them, out-of-range accounts that block Continue are named, nameless sources say so. - sie-import: skippedVouchers.unmappedAccounts (per account, voucher count) via summarizeUnmappedSkips; warning names the accounts. - Migration result step: names created accounts and the accounts behind "med ej kopplade konton". Closes #2212 Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8313f527c9 |
fix(migration): complete-invoice-lines cron visits registers smallest first (#2341)
* fix(migration): complete-invoice-lines cron visits registers smallest first The hourly pass ordered its work by consent recency, which says nothing about work size: a 1 125-invoice register on the newest consent used two runs in a row while a 384-invoice register three consents older was skipped for budget both times. Each run now sizes every usable consent's register on our side first (one indexed HEAD count of the non-draft invoices without rows, no provider call) and then hands the registers with anything left to the pass smallest first, each within its share of the run. Shortest job first: a register that fits its share is done this run whatever was accepted after it; the one that needs several runs takes what is left of each. Nothing is stored between runs, and the budget constants are unchanged. What a run does not reach is by construction its largest registers; they are logged and returned as `deferred` with their counts so a register that is deferred hour after hour is visible. The count is proven against a real PostgREST (tool-pg) because `invoice_items=is.null` on a to-many embed is resolved there, not in Postgres or the type system. Closes #2309 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(schema): teach the phantom-column guard PostgREST's embed-null filter The static guard read `.is('invoice_items', null)` as a column of `invoices` and failed CI on #2341. PostgREST's null filter on an embedded resource (`?invoice_items=is.null`, the anti-join on a to-many embed: the parents whose embed is empty) names the embed declared in the same chain's select, not a column. The scanner already registers every embed alias per chain for dotted filters; a bare name that is a registered embed, used with `is` (or `not` / `filter` with the `is` operator, the only operators that reach an embed), is now recognised and checked no further. Any other operator on a bare embed name, and `is` on a name the select never embedded, are still accused, with cases for both. The grammar itself is proven on a real PostgREST by complete-invoice-lines-count.tool.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
238cbe13f9 |
feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules A yearly or quarterly recurring schedule had no way to say which month it bills in: the dialog exposed interval and day of month only, so a yearly schedule created in September always fired in September. The phase of a schedule is fully defined by its first run date, which the table already stores as next_run_date and the create API already accepted as start_date but nothing exposed. - Dialog: new date field (first invoice date on create, next invoice date on edit), prefilled with the next natural occurrence so the default is "no offset"; kept in step with day of month both ways; shows the following three run dates so the phase is visible. Sent as start_date on create and as next_run_date on edit only when the user actually re-phased. - API: create validates start_date (on the day_of_month grid, not in the past); update accepts next_run_date (on the grid for the effective day, strictly after today in Stockholm) and lets it win over the automatic recompute a day change or reactivation does. - Staged operations / MCP: start_date documented as the phase; update tool gains next_run_date. Commit executor rejects off-grid dates and rolls a date that went stale before approval forward on its own grid. - lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared by the dialog, the routes, the executors and the cron service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC * fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog Resolves the skeptic and CI findings on #2338 in one pass: - MCP staging tools now apply the same grid and past/future rules as the routes to start_date and next_run_date, so the preview a human approves is exactly what the commit executor writes (previously an off-grid date staged fine and failed at approval, and a past next_run_date was rolled to another date silently). - The dialog computes today and the default first invoice date in Europe/Stockholm instead of the browser's zone, matching the server; getStockholmDateHour moved to the client-safe module and is re-exported from the service. - gnubok_update_recurring_schedule description trimmed under the 280-char limit while keeping the clamping and Stockholm phrases the registration test requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c0eda46354 |
feat(mail): withhold new Gmail consents on hosted unless the company is allowlisted (#2320)
Every Gmail consent shows "Google hasn't verified this app" until the restricted-scope review closes, and a prospect bounced on it today. Jakob's call: remove the connector in the meantime rather than explain the screen. New consents are gated by GOOGLE_MAIL_CONNECT_COMPANY_IDS on hosted: unset means nobody (the default from this deploy on), `*` means everybody (set once Google approves), a comma list means those companies (the reviewer's demo company, the company the video is recorded in). Enforced in /oauth/start (403 connect_disabled) and mirrored as connectEnabled on /connections, so the settings page drops its connect button and the inbox start card falls back to plain upload. Existing mailboxes stay listed, keep being searched and can be disconnected. Self-hosted installs run their own Google app and are never gated. Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
473b1fd2eb |
fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)
* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials Every Björn Lundén connect in prod has failed with "Leverantören avvisade autentiseringen" (10 consents since June; only BL's own sandbox company ever received tokens). Live-verified against a real customer User-Key today: BL answers 403 "<service>:READ is out of allowed scope for service provider Arcim" on every read endpoint. The key is right and binds the company; the company has simply never activated our integration, and it cannot until BL moves the listing out of sandbox. The generic 403 mapping told the user to re-check what they pasted, which can never help. - BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError, matching the verbatim live 403 and 500 bodies. - submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind 'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our own client_credentials token refused) rethrows as a generic submit failure instead of blaming the pasted key. - New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix (activate under Integrationer in Lundify, else SIE) and where the GUID is. - Wizard copy for BL moved to i18n keys and reordered: activate first, then paste the key; the key only works once the integration is activated. - Tests: route mapping for both kinds, probe classification incl. the captured live bodies, registry entries pinned to 422. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY * fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up User-Key answered 500 with a Spring BeanCreationException for databaseConnector, not the null getCurrentUser() message captured earlier. The unknown-key verdict already keys on the status alone in submitProviderToken; keep only the 403 scope matcher, whose body IS stable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
41a5728ca7 |
fix(expenses): review follow-ups from #2317 (#2326)
- The Utlägg nav row is computed server-side, so the first booked claim now refreshes the App Router tree instead of staying hidden until a full reload. - The dialog's default date is the local calendar date; toISOString() is UTC and dated a receipt booked after midnight CEST to the previous day. - listExpensePayoutsDue pages through every registered claim with fetchAllRows instead of stopping at 500 rows: a person omitted or a total understated there is money the company owes someone. - The attention resource's payout instruction names the liability account per row (2893 / 2018 / 2820) instead of only 2893/2820. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cbe5580886 |
feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)
An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
971952fe19 |
fix(mail): request gmail.readonly alone, mailbox address via Gmail profile (Google verification) (#2301)
* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a "scope discrepancy": the authorization URL asked for `openid email` on top of gmail.readonly, while the Cloud Console declares gmail.readonly only, and the review string-matches the two. The extra scopes existed solely to learn the mailbox address from the id_token. Gmail's users.getProfile returns that address under gmail.readonly, so the consent request now carries exactly one scope and the callback reads the address from the profile. Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's reviewers log in with credentials we hand them and treat a second factor as an "authentication blocker"; app_metadata is service-role only, so this is an operator switch for demo accounts, never a user-reachable setting. Tests: scope pinned in google-oauth.test.ts, profile read in gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in mfa.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ * fix(auth): time-box the reviewer MFA exemption instead of a boolean flag Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt relied on someone remembering to clear it. The exemption is now app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies in the future, so a forgotten flag dies on its own. Anything malformed or non-string enforces MFA. Still service-role only, still meant for the one demo account Google's OAuth reviewers log in with. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0ad83b8d71 |
feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party Founder feedback on the first Företagsuppgifter (2026-09-05): the org number twice, the VAT number twice, the legal name repeating the heading, and Kontaktuppgifter showing dashes while the block above had the phone, e-mail and address from SCB. - After a fetch the register's contact details land on the supplier and customer rows that point at the party: an empty field, or one still carrying what the register said last time, takes the new value; a value a person typed stays. Shown as "från SCB" on the row (by equality with the registry fact, no source column). - Företagsuppgifter becomes one status line (legal form, active or not, registrations, a Bolagsverket warning when there is one), industry, seat with registration date, and size. Identity stays in the header (org number now formatted) and Kontaktuppgifter. The legal name shows only when it differs from the row's name. - lib/parties/registry-summary.ts reads the coded SCB facts once for the page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape. - v1: party_id on supplier and customer list rows and detail; ?expand=party on detail embeds identity, the register summary, what the ledger has seen and payment identities. MCP: party_id on gnubok_list_suppliers/customers rows and gnubok_get_party (by party, supplier or customer id). Read-only; the parties resource follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): regenerate the API skill for the party expansion; tighten the get_party description Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f7f40c04e4 |
fix: return provider OAuth callbacks to the initiating brand domain (#2305)
* fix: hand provider OAuth callbacks back to initiating brand * fix: encrypt provider OAuth handoff payloads * fix: purge expired provider OAuth handoffs |
||
|
|
34bf5a7387 |
fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers (#2304)
* fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers Three shapes seen on live Profilio payloads after #2302's rows-versus-header check went in: - Freight and AdministrationFee live on the invoice header, not in InvoiceRows, while Total and TotalVAT include them. The rows summed to less than the header by exactly the charge and the check refused the invoice (14 of Profilio's 384). They are now rows: FreightVAT and AdministrationFeeVAT are VAT amounts (88 and 22 on a 25 % invoice), and the charge is gross when VATIncluded is true (99 = 79.20 + 19.80). - Free-text rows (DeliveredQuantity "0", Total 0, VAT 0) counted as a stated 0 % rate beside the 25 % rows, so the migration marked the invoice mixed and nulled its header rate on roughly half of two registers. They no longer state a rate, and land as line_type 'text' with no amounts, the way the invoice page and the booking engine expect them. - DeliveredQuantity is serialised as a string and was stored unparsed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf * fix(migration): type the text-row check so resolveInvoiceVat's line shape accepts it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e80ea74e76 |
fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)
The first production run of the row-completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT computed on top, beside a header (Net / TotalVAT) that was right. Fortnox prices an invoice either excluding or including VAT and says which with the invoice-level VATIncluded flag; the mapper had always read the row Total and Price as net. Every such row set is 1.25 x its header net, to the öre, across all 345. - lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price by (1 + rate) when VATIncluded is true; TotalExcludingVAT and PriceExcludingVAT are preferred when the payload carries them. A row without a rate cannot be split and keeps its amount. - complete-invoice-lines.ts: rows whose net or VAT disagree with the header the same payload established by more than 1 kr are reported as rowsMismatch and left untouched. Öresavrundning stays inside the tolerance; VAT-inside rows, header-level freight and discounts do not. Rows that contradict their own header are worse than no rows. - Cron summary carries rowsMismatch. None of the 345 is open or booked; a separate repair removes today's rows for them so the fixed pass refills them. Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8e1f9d5201 |
fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach (#2291)
* fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach The migration maps sales invoices from the provider's list payload and hydrates the detail form (rows, net, VAT) inside a fixed 90 s budget, open invoices first. Fortnox, Briox and Björn Lundén ship no rows in a list response, so every invoice the budget did not reach was imported as a header with a total and no invoice_items, and nothing ever came back for it: the wizard never showed the hydration report, so the user found out on the invoice page. Measured on prod today: Profilio 384 of 384 (migrated before hydration existed), Loftux 311 of 672, Damac 182 of 542, Clearstoq 1 125 of 1 125. - lib/providers: hydrateSalesInvoices() hydrates a caller-chosen subset of an already-listed register, so a follow-up can spend its budget on the invoices still incomplete on our side instead of re-walking the register open-first and never reaching the rest. - arcim-migration: completeMigratedInvoiceLines() starts from OUR row-less non-draft invoices, joins them to the provider register on number + date (unique on both sides), hydrates only that subset and writes each invoice's rows once the detail total matches the stored total to the öre. The header VAT split is rewritten only when the stored one holds no evidence (null rate, or a non-zero rate label beside 0 kr VAT and subtotal = total). Never the total, status, payments or a journal entry. - Hourly cron (/api/extensions/arcim-migration/complete-invoice-lines/cron, vercel.json + Docker crontabs) drives the pass over consents accepted in the last 60 days, newest first, with a per-company share of the run. - The wizard's result screen now shows "x av y fakturor hämtade med rader" and that the rest are fetched in the background within the hour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf * fix(migration): write the header VAT fill as a literal, raise the schema-guard ceiling for the row inserts The phantom-column scanner resolves only object-literal payloads. The header update is now a literal (so its six columns are checked); the two invoice_items inserts are runtime row arrays from mapSalesInvoiceLine, the same shape the orchestrator already inserts, so the ceiling moves 399 to 401 with the reason recorded beside the earlier ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf * fix(migration): gate the completion cron on token freshness, not consent age, and visit every usable consent Two review findings held. Prod holds 57 accepted consents from the last 60 days, so a fixed page of the newest 25 would leave older companies with row-less invoices waiting behind companies that are already done: the cap is gone (a company with nothing left costs one query and no provider call). And the consent's created_at said nothing about whether its credentials still work: Fortnox refresh tokens live 45 days and rotate on every refresh, so eligibility is now read off the token row (access token expired within the last 45 days, or no expiry at all), which also stops a dead consent from being retried every hour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1e1e1d5d17 |
fix(enable-banking): connector-hop failures no longer park a connection in error (#2296)
* fix(enable-banking): connector-hop failures no longer park a connection in error On 2026-09-04 the daily cron flipped four canary companies (bank sync routed through Accounted Connect, PR #2205) to 'error' with "Banksynkningen misslyckades ... forny anslutningen" because the service answered 200 with a body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected shape" message left the contract mismatch undiagnosable. - New ConnectorSyncError (status, code, body, Zod issue paths) thrown for every connector-hop failure: transport, timeout, error envelope other than a dead session, and wire-contract mismatch. The failing field paths are logged at the throw site. - Cron: AspspUnavailableError and ConnectorSyncError are transient. The row is left untouched (no status flip, no renewal advice) and logged at warn level; the health probe on the same run still catches a dead session. - Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that the connection does not need renewing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L * fix(enable-banking): keep the connector response body out of the sync log line Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw connector body next to user and connection ids. A connector response can carry transaction and personal data, so the log line now carries only code, status, the Zod issue paths and the body length. The BAD_SHAPE message no longer falls back to the body either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1976129478 |
feat(import): let the Fortnox import fetch older fiscal years: the three-year limit becomes a default selection (#2280)
* fix(import): say which fiscal years the Fortnox connection fetches, list the ones left out Root cause: the guided provider migration fetches SIE only for fiscal years that start within a rolling three-calendar-year window (getAllowedFiscalYears in extensions/general/arcim-migration/lib/sie-fetcher.ts: current year and the two before it). A first, broken year 2022/2023 starts in 2022 and falls outside the window in 2026, and the wizard said nothing: not before the import, not after. Users concluded the books were complete, or that they had done something wrong (issue #2211, second report via support 2026-08-27). Fix: - The fetcher already lists every fiscal year at the source before applying the window, so the left-out years are derived from that same list at no extra provider call: `omittedYears` (years starting before the window, oldest first, with the provider's own from/to dates so a broken year is named as "2022-09-01 till 2023-12-31"). Fortnox and Briox year refs now carry those bounds; WINT's listYears reports the unfiltered year list. - GET /preview returns `fiscalYearWindow` and `omittedYears`; GET /sie-data returns `omittedYears` next to `failedYears`. - Wizard, preview step (before the import runs): one muted sentence that the direct connection fetches the three latest fiscal years (years starting in {fromYear} or later); when years are left out, they are named with a link to the SIE import (one SIE file per year under Import, oldest first). - Wizard, result step: a "Räkenskapsår som inte följde med" section naming the omitted years with the same SIE pointer, shown when SIE data was imported in the run. - MCP: the connect_migration tool description, its instructions and the onboarding skill claimed the wizard "fetches every fiscal year"; they now say three latest, older years via SIE. - Strings in both messages/sv.json and messages/en.json. Out of scope: fetching more years through the connection (#2238). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u * feat(import): make the Fortnox import's three-year limit a default selection, not a cap Root cause, from first principles: the guided provider migration fetched SIE only for fiscal years starting within a rolling three-calendar-year window (getAllowedFiscalYears in extensions/general/arcim-migration/lib/sie-fetcher.ts, introduced in #718 with no stated reason). The window was a silent cap: the wizard never said it existed, and a first broken year 2022/2023 simply never arrived (#2211). The user's actual problem is that the year is missing from the books, so explaining the cap (the previous commit) treats the symptom. What the window gated, by evidence: only the SIE fetch. Documents already list every Fortnox financial year and match against the vouchers that exist locally (import-documents.ts), invoices, customers, suppliers and assets are not year-gated, and the SIE import itself is one request per year (hosted function limit 300 s, import_sie_journal_entries statement_timeout 290 s), so its cost is linear in wall time and bounded per year regardless of how many years are imported. The only place the number of years multiplies inside one invocation is /preview and /sie-data: one SIE export per year (Fortnox client: 15 s per-call timeout, 3 attempts, backoff up to 30 s, 4 req/s) fetched and parsed inside a single 300 s function, and /sie-data returns every raw file in one response. The repo holds no measurement of Fortnox's per-year SIE export latency, and the maintainer's memory is that a full history can take unreasonably long, so a fixed lift to every year cannot be shown safe for a long history. Fix: the window becomes the DEFAULT selection, and the user chooses. - sie-fetcher: fetchProviderSieFiles takes `years` (explicit start years); without it the default window applies. The result carries `sourceYears` (every year at the source, oldest first, with the provider's own bounds and an inDefaultSelection flag) and `omittedYears` (source years outside the selection). Both derived from the year list already fetched: no extra provider call. Fortnox and Briox year refs carry their bounds; WINT's listYears reports the unfiltered list and its voucher chain follows the selection. - GET /preview returns `sourceYears`; GET /sie-data honours `?years=` (validated, deduplicated, oldest first; 400 VALIDATION_ERROR when malformed) and returns `omittedYears`. PROVIDER_SIE_NO_YEARS names the selection. - Wizard, preview step: a "Räkenskapsår att hämta" picker with one checkbox row per source year, the three latest ticked by default, older years marked "tar längre tid"; Fortsätt is disabled with an attn line until at least one year is ticked. The selection is sent to /sie-data, so each extra year is the user's own wait, and it fails loudly there, before any ledger write, if it is too much. - Wizard, result step: the per-year lines already report exactly what was imported; a "Räkenskapsår som inte hämtades" section names the source years outside the selection, with the re-run path (documents come along) and the SIE path. - MCP connect_migration description, instructions and the onboarding skill say "three latest by default, older years selectable" instead of "every fiscal year". - Strings in both messages/sv.json and messages/en.json. Closes #2238 as well: the wish to fetch more years is the same control. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u * fix(import): bound the fiscal-year selection per import run Superagent P2 on #2280: the `years` selection was unbounded and every selected year is one provider export fetched and parsed inside the single 300 s /sie-data invocation, so nothing bounded the work before provider calls. The bound: MAX_SELECTED_FISCAL_YEARS = 6, exported from sie-fetcher.ts with the derivation. One export call is 15 s per attempt (Fortnox client FETCH_TIMEOUT_MS), 3 attempts with 1 s and 2 s backoff (retry defaults), so a year that times out on every attempt costs 48 s; six such years are 288 s, leaving 12 s of the 300 s hosted function for the year listing, parsing and the response; seven would be 336 s. Enforced server-side: - /sie-data refuses a selection of more than the cap with 400 VALIDATION_ERROR naming the cap, before the consent is resolved, so an oversized request does no provider work. - fetchProviderSieFiles throws FiscalYearSelectionError for a selected year the source does not have, right after the year listing and before any export; /sie-data maps it to 400 VALIDATION_ERROR naming the year. - /preview returns maxSelectedYears so the picker enforces the same number without a client-side copy: Fortsätt is disabled and an attn line says how many can be fetched at once and that older years go in a second run (sv + en). Tests: cap accepted at 6 and refused at 7 with no provider call, unknown year refused (route and fetcher), the cap's arithmetic, maxSelectedYears on /preview. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7240bfe7f3 |
fix(mcp): supplier-invoice-from-inbox resolves FX through the shared resolver, with the cache and an override (#2173)
* fix(mcp): supplier-invoice-from-inbox resolves FX through the shared resolver, with the cache and an override MCP feedback seq 299742: eight USD supplier invoices staged from the inbox in one batch, three got a Riksbanken rate and five came back exchange_rate: null / exchange_rate_source "lookup_failed", reproducibly, for ordinary weekdays in April-August. None of the five could be approved (the executor refuses with SI_FX_RATE_MISSING; it never books 0 SEK), and the tool offered no way to supply the rate. Cause: the tool called fetchExchangeRate without the supabase client, so neither the shared exchange_rates read-through cache nor the last-cached-observation fallback was reachable. Riksbanken's IP limiter answers 429 after about five requests in a burst (a weekend date costs two: exact-date 204, then the 7-day range), sends no Retry-After header, and asks for ~54 s, which the 5 s retry cap cannot honour. The pass/fail split was request ordering, nothing about the dates. - Resolve through resolveSupplierInvoiceExchangeRate with the client, the same resolver the commit executor and the v1/web write paths use, so the staging preview and the commit agree and the cache is consulted and warmed. - New input exchange_rate_override (SEK per 1 unit of invoice currency), trusted verbatim like the web form and v1; validated positive and finite, refused as implausible past the resolver's bound, rejected on a SEK invoice. Source is echoed as "supplied". - When the lookup still fails, the preview carries exchange_rate_hint saying approval will refuse and naming the override that unblocks it. tools/list: +1 property (~25 tokens), ledger line added in payload-size.bench.test.ts; ceiling unchanged. The retry cap is left as is: waiting a minute inside a tool call or the sync cron's fan-out is a design call, and the cached fallback now covers the common case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 * docs(decisions): FX override and retry cap on the inbox supplier-invoice tool Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8265b5d166 |
feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search After a SIE migration or verifikat backfill, customer invoices exist only as journal entries: the invoice list, kundreskontran, /api/invoices, v1 invoices.list, and MCP list_invoices all looked complete while silently omitting everything before the register's first invoice (user report: two invoiced fees nearly re-invoiced as "uninvoiced"). - lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest register invoice; flags posted non-invoice-engine AR verifikat (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so manual/API backfills are caught too. - Invoice list page: one attn line disclosing the boundary (sv+en). - Kundreskontra: register_coverage in the report payload, rendered in the summary card and as an explanation under "Ej avstamd". - /api/invoices GET: invoice_register_coverage in the response. - v1 invoices.list: meta.coverage + registry pitfall documenting it. - MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on the first page, pointing agents at gnubok_query_journal. - Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross amounts with sv-SE formatting, alongside number/customer matching; a known net amount like 14 000 now finds the 17 500 kr row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill Skeptic + CI findings folded into one pass: - Coverage probe: a failed AR lookup now degrades to UNKNOWN (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete". - Probe driven from journal_entries (company-indexed) with the AR line condition as an inner embed, instead of the lines-table-with-embed-filters shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts). - DEBIT-only 1510/1513 lines; excludes every invoice-engine source type (invoice_created, invoice_paid, invoice_cash_payment, credit_note, reminder_fee, rot_rut_payout, storno, correction): an advance payment crediting 1510 or a re-dated rattelse of an engine entry no longer flags. - covers_from ignores drafts so a backdated draft cannot move the boundary. - Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR debits existing IN the reconciled period (new ARReconciliationResult.pre_register_ar_in_period): prior-period migration history cannot explain this period's difference and must not excuse a real felbokning. Wording no longer says "snarare an felbokning". - MCP coverage_note states the earliest register invoice date rather than claiming the register "covers" from it. - Amount search compares magnitudes so credit notes (negative totals) are findable; "-17500" parses; null amounts never match "0". - skills/accounted-api regenerated from the registry (apiskill:check). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * chore(api-skill): regenerate accounted-api skill after merging origin/main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): round-2 review fixes for register-coverage disclosure - covers_from now anchors on real invoices only (document_type='invoice', non-draft): proformas/delivery notes cannot move the boundary. - INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers (invoice-entries, reminder-fee, rot-rut, storno-service) so a future source_type cannot silently become false pre-register evidence. - Kundreskontra guidance names both 1510 and 1513. - MCP gnubok_list_invoices outputSchema declares invoice_register_coverage and coverage_note. - v1 reports.ar-ledger documents data.register_coverage; invoices.list example made internally consistent; api skill regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget The expanded schema from the round-2 review pushed tools/list to 61 726 tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The ceiling is policy, not a baseline to bump: the description already tells agents to read invoice_register_coverage/coverage_note, and paginatedSchema has no additionalProperties:false, so the fields stay schema-valid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> |
||
|
|
67e878fb23 |
fix(mcp): reject unparseable voucher lines and allocation kinds; already-booked and unlinkable say why; N:1 reconcile groups survive staging (#2171)
* fix(mcp): reject unparseable voucher lines and allocation kinds; already-booked and unlinkable say why; N:1 reconcile groups survive staging
Four MCP feedback reports about the same failure class: the server does no
runtime validation of inputSchema, so a shape mistake was coerced into a
wrong-but-well-formed call, and the error the agent finally saw pointed at
the wrong thing.
- create_voucher / correct_entry: a line naming neither debit_amount nor
credit_amount was `Number(undefined) || 0`-ed into 0/0, and the balance
check reported "debits 0 SEK, credits 0 SEK" for four perfectly balanced
formats ({debit}, {amount, side}, {debitAmount}, signed amount). The line
shape is now checked first, the error names the keys it got and shows a
valid line, and a non-numeric amount is rejected as such (seq 318571).
- match_batch_allocate: kind is the key every guard branches on (direction
vs sign, required id per kind, tenant pre-check on the invoices). With
kind absent none of them fired: an incoming +50 359 SEK payment against
three kundfakturor staged as allocations_kind "supplier_invoice" with zero
invoice checks. A missing or unknown kind is now rejected before any
query, with the id field that goes with each kind (seq 319919).
- categorize_transaction on an already-booked transaction returned the
core's success-shaped object, which fails STAGED_OPERATION_SCHEMA on
strict clients: the agent saw "Structured content does not match the
tool's output schema" and never the reason. It now throws, naming the
existing journal_entry_id (seq 288574).
- reconcile_match: the dry run flattens a pair into one link per outside
row, and the staging rebuild put each back as its own 1:1 pair, so an N:1
group (Skatteverket "Avdragen skatt" + "Arbetsgivaravgift" against one
1630 verifikat, sum exact) reached the executor as N pairs each asked to
settle the whole verifikat: PAIR_NOT_CLOSED on all 18. Links sharing a
verifikat now fold back into one pair, mirroring the existing 1:N fold,
and "No linkable pairs" carries the dry run's skip reasons (seq 292682).
No tools/list payload change: no schema text touched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3
* docs(decisions): N:1 reconcile fold at staging
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
01d903d3a3 |
fix(mcp): link_document_to_voucher marks the inbox item handled; document lists stop misleading agents (#2170)
Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit the same hole: link_document_to_voucher attaches the document to the verifikat but never stamps the inbox item it came from, and both inbox read surfaces derive "handled" from the inbox row's own link columns, never from document_attachments.journal_entry_id. So an attached document stayed "unprocessed" forever: agents re-saw it as missing underlag (one reporter paged 750 rows to find the ~15 real ones), and one user read the five leftover rows as duplicates and was about to delete the only copies of underlag sitting on posted verifikat. - commitLinkDocumentToVoucher and the bulk twin now stamp invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on both link columns, 23505 tolerated (samlingsverifikat). Same shape as the create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping never rolls back a committed link. - gnubok_list_unmatched_documents returns file_name (same embed list_inbox_items uses) and the extraction's page coverage, so an agent can tell "no total on this document" from "we read 3 of 38 pages" and does not have to fetch each document to learn what it is (seq 265062, 288574). - gnubok_list_transactions_without_documents no longer echoes the column default "uncategorized" on rows that are booked by construction: list_uncategorized_transactions uses the same word for "no journal entry yet", and an agent read the label and tried to re-book an already-booked share-capital deposit (seq 288574). No tools/list payload change: the unmatched-documents item schema is untyped and the category field already allowed null. Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
bac9e01e2d |
fix(arcim): offer the company's own accounts as mapping targets (#2164)
The Fortnox migration's account mapping step builds its target dropdown from BAS_REFERENCE alone: the 1290 standard accounts. Any account a company created outside the standard cannot be selected as a target. Seen on a live Fortnox migration: 3005 "Provisioner inom Sverige" was active in the chart, returned by /api/bookkeeping/accounts, visible everywhere else in the app, and missing from this one list, because BAS defines 3000-3004 and stops there. The plain SIE import at app/(dashboard)/import already used the company's own chart (fetchAccounts(false)), so the two routes into the same AccountMappingStep disagreed about what could be mapped onto. Targets are now the company chart unioned with BAS, deduplicated by account number with the company row winning: its name is whatever the user renamed the account to, and that is the label they look for. BAS stays for standard accounts a first migration is about to create, and a failed chart read degrades to BAS rather than throwing, since an incomplete list still lets the migration proceed while an exception stops it. |
||
|
|
158108ec01 |
fix(bank): keep other companies' accounts out of the EB account picker (#2141)
* fix(bank): keep other companies' accounts out of the EB account picker At one-session banks (SEB) a single BankID consent returns every account the signer can see across all their companies, so a reconnect from company A carries company B's accounts. PR #2116 made those arrive unchecked, labelled and unmirrored; they were still listed in company A's picker and in the connection's account list in settings, which read as "the wrong company's data in my books" (user report, Deepgrid group). - New lib/claimed-accounts.ts: partitionByClaim() splits a connection's accounts on claimed_by_company_id; describeClaimedElsewhere() renders the one-line Swedish summary. Unit-tested, including the legacy double-claim (no flag, stays own) and carried-deselection cases. - AccountPickerDialog: main list, "Markera alla" and the "x av y valda" counter cover own accounts only. Claimed accounts sit behind a collapsed "N konton synkas i <bolag>" disclosure (still tickable: a claim is a strong hint, not proof of ownership). Row markup extracted into renderAccountRow so both lists share it. - BankConnectionStatus: foreign rows dropped from the details list and the "x av y konton synkas" count; one muted summary line instead. No data or callback changes; brand-new never-claimed accounts still list unchecked, since Enable Banking's account resource carries no owner org number. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi * fix(bank): close the review and skeptic findings on the claimed-accounts picker Review (CodeRabbit): describeClaimedElsewhere decided "same claimant" on the display name; two companies can share a name. Now keyed on claimed_by_company_id as well, with a test. Skeptics (correctness + regression): - The empty-own-list message asserted "synkas redan i andra bolag" even for a consent with no accounts at all (failed connect, nothing ticked at the bank). Now only when claimed accounts exist; otherwise a plain "inga konton" message. - "Markera alla" stayed enabled but inert with zero own accounts: allSelected is now vacuously true there, so the button disables. - A claimed account ticked inside the disclosure kept counting after the disclosure was collapsed: the disclosure line now names the ticked count so the "x av y valda" counter never exceeds what is visible. - The pending_selection row in settings still counted foreign accounts ("3 konton tillgängliga" beside a picker saying none): now own accounts, with a dedicated line when everything is claimed elsewhere. - Claim flags did not survive an in-place renewal (accountsMetadata is rebuilt without them and the guard skipped seen-on-row accounts), so the sibling's accounts returned to the main list unlabeled on the next reconnect. The callback now re-derives the label from a fresh lookup for accounts that stay disabled here; released claims clear themselves. Two callback tests. Skeptic (compliance) hardening: - partitionByClaim requires enabled === false alongside the flag, so a flagged-but-enabled row (any future writer) can never hide a syncing account. - The sibling company's name is data-ph-masked on the settings summary line and the disclosure line, matching the row label. Declined: CodeRabbit docstring-coverage warning (repo has no docstring requirement; the touched functions carry inline rationale comments). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi * fix(bank): keep a re-stamped sibling claim out of the cash-account mirror CodeRabbit round 2: the renewal branch that re-derives the claim label left the account out of guardDisabledUids, so the mirror below still ran upsertFromPsd2 for it. The first connect never mirrored that account (#2116), so a renewal would have planted the sibling's IBAN in this company's cash_accounts and burned a 19xx slot for an account that stays off. Now excluded like a fresh claim; the renewal test asserts only the own account is mirrored. Declined (recorded for the summary): compliance-swarm advisory that the sibling's account metadata reaches the client. Both companies belong to the same signed-in user and the data arrives under that user's own PSD2 consent; the ownership decision is already made server-side in the callback, the picker only renders it. Non-blocking, no cross-user data. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d670fe6663 |
feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account Enable Banking has no top-level `bban` key on AccountIdentification: a Swedish BBAN (clearing + account number) arrives as `other.identification` with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed `bban?: string` and read `.bban`, so the value was always undefined: no connected account ever carried its clearing + account number, and domestic counterparty accounts on transactions were dropped. Type the identifiers per the OpenAPI spec, add extractBban() and pickAccountIdentifier(), read counterparty identifiers through the scheme list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on StoredAccount from the OAuth callback. The external_id dedup scope stays IBAN-then-uid and is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): named payee accounts on cash_accounts with a default per currency A company had exactly one set of payment instructions per invoice currency (company_settings.invoice_payment_accounts), picked by currency alone. A second SEK bank account, or a second bankgiro number, had nowhere to live. cash_accounts is already the per-company bank-account entity. Migration 20260903150000 adds the payee fields (bankgiro, plusgiro, clearing + account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a small invoice_payee_defaults table (one default account per currency; one account may be the default for several currencies, a SEK account with an IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites the legacy map and the SEK bank columns from the default accounts. Every existing reader (PDF, email, reminders, v1, MCP) keeps working; the three writers that only touched legacy columns (PUT /api/settings, v1 settings, MCP update_company_settings) now write through to the default account, so what an agent sets is what the PDF prints. Peppol PaymentMeans is built from the resolver instead of the raw legacy column. bg_pg is dropped (never read or written; NULL on every prod and staging row). Backfill lands only on existing cash accounts (primary, IBAN match, or the only enabled account in the currency). Entries with no target stay in the map as the resolver fallback and get an attach action in settings. New: POST /api/cash-accounts (manual bank account on the next free 19xx), PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT /api/cash-accounts/payee-defaults. Settings page rewritten as an account list with per-currency defaults. Behandlingshistorik and the full archive cover the new table and columns. Verified on staging: migration applied (11 defaults landed), mirror trigger observed rewriting company_settings from a payee edit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): choose which bank account an invoice is paid to, frozen at issue Migration 20260903160000 adds invoices.payment_cash_account_id (FK to cash_accounts, SET NULL) and invoices.payment_details, the payee fields frozen when the account is chosen and refreshed at issue. Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount / assertInvoicePaymentAccountForRender take an optional override, and hasRequiredInvoicePaymentAccount reads it from the invoice row, so every surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol, recurring, staged MCP send) prints the frozen payee when one exists and the company default per currency otherwise. Invoices that never chose an account behave exactly as before. Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send, recurring, MCP send and mark-sent) refresh the snapshot from the account as it is at issue; a chosen account that is disabled, un-flagged or unusable for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID. Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice accept payment_cash_account_id and validate it against the company's payee accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the original's payee; copies carry the choice; preview-pdf renders the chosen account. The editor shows "Betalas till" under the currency when the company has two or more usable payee accounts for that currency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): book manual payments on the invoice's chosen bank account Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the booking dialog's proposed lines debited 1930 regardless of which bank account the invoice asked to be paid to. They now resolve the chosen payee account's ledger account (resolveInvoiceSettlementAccount) and fall back to 1930 only when no account was chosen or the row is gone. Bank-transaction matching keeps debiting the account the money landed on and does not filter by the chosen account; between equal-confidence candidates it prefers the invoice that asked to be paid to the landing account. Scores are untouched, so nothing new auto-matches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(invoices): keep the payload-size and phantom-column ceilings after the payee work Shorten the new gnubok_create_invoice argument description (tools/list payload was 29 bytes over the 60 kB budget), inline the cash-account payee UPDATE/INSERT payloads and the settings select strings as literals so the phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE instead of a hand-rolled copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK) Review findings from CodeRabbit, Superagent, the Swedish accounting review and three skeptic passes, resolved in one batch: Schema (both migrations are unshipped and edited in place): - cash_accounts.payee_iban: the printed IBAN is its own column. iban stays the bank identity written by every sync and used to re-pair on reconnect, so a sync can no longer rewrite an invoice instruction or resurrect a cleared IBAN. The backfill copies each currency entry verbatim onto the target account (IBAN match first, then primary), so every invoice keeps printing exactly what it printed before; the bank IBAN is never pushed onto invoices that did not carry one. - Payee columns are owner/admin-only at the database (BEFORE trigger, service role exempt): cash_accounts is member-writable for bank sync, and the SECURITY DEFINER mirror would otherwise have let a member rewrite where customers pay. - Revoking an account as payee or disabling it drops its defaults; deleting a default drops that currency from the map and clears the legacy SEK columns (an admin saying "nothing to print" must not keep printing a closed account). The mirror leaves the legacy SEK columns alone when the map has no SEK entry, so legacy-only companies are never wiped by a mirror run for another currency. - Audit and mirror triggers fire on the same column set; anon and authenticated can no longer execute the trigger-only definer functions. - invoices.payment_cash_account_id is a composite same-company FK with SET NULL scoped to the account column. Code: - Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now also requires enabled, payee-flagged and usable for the currency), resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which also refuses disabled rows and logs every fallback to 1930). - createManualBankAccount excludes every ledger slot any row already holds (findFreeLedgerAccount treats a manual holder as free; this path inserts). - The legacy settings writers (PUT /api/settings, v1, MCP) write through to the account BEFORE updating company_settings and fail the request on error; the account is written before it is adopted as default so the mirror never sees an empty payee. - snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid projections carry the payee columns; v1 create validates the payee before the dry-run return and echoes it in the preview. - pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and non-account schemes (card PANs) are never persisted. - Editor shows the payee select for a single usable account with no default; the booking dialog waits for cash accounts before proposing lines; a failed default write no longer hides a created account. - Behandlingshistorik names the account on created/deleted defaults. - Regenerated skills/accounted-api; MCP argument description trimmed under the tools/list payload ceiling. Declined: clearing legacy columns via a forward migration (the mirror now does it on delete); Swedish review's "show the debit account in the mark-paid UI" (the booking dialog already proposes and lets the user edit the debit line); manual ledger collision (UNIQUE exists, and the create path now rejects it with a clear error); Peppol aligning to the PDF value for companies whose legacy column had drifted from the map (the PDF is the customer-facing document; both now agree). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves record fields per expression, so the combined condition failed with "record new has no field invoice_payee" whenever a default row changed, which took down every pg-real case on the payee tables. The revoke/disable check now sits inside its own TG_TABLE_NAME branch. The MCP settings executor test mocks the payee write-through like the settings route test already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload Cycle 3 of /resolve-pr on #2233. Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's invoice_payee_defaults rows whenever cash_accounts.enabled flipped to false, and enabled is member-writable (the bank picker's "Synkas ej"), so a member could undo an admin's payee decision. The trigger now drops defaults only on the admin-only invoice_payee true -> false revoke; the mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out of the pick lists and the send gate already refuses an invoice that chose one. Applied to staging as the same function + trigger definition and probed inside a rolled-back block: disable keeps the default and the mirrored bankgiro, revoke clears both. pg-real: the admin-guard test ran three expectations inside one withUserContext transaction; the first raise aborted it and the next statement failed with "current transaction is aborted". One transaction per expectation now, and the member case also flips enabled to prove the column stays member-level. Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa and the 1911-1919 tills. A customer pays to a giro or bank account, so isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH route now require BAS 1920-1999; tests cover 1910 and 1919. Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014 tokens after main merged #2166 and #2163 alongside this branch. The ceiling is not bumped and no read on this surface is a demotion candidate, so gnubok_create_invoice drops payment_cash_account_id; agent-created invoices print the per-currency default and v1 REST plus the editor keep the field. Recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration origin/main merged 20260903160000_kpi_monthly_include_reversed_originals while this branch held the same version; identical versions abort the Supabase apply. Staging's schema_migrations row was moved to the new version with the file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet Cycle 4 of /resolve-pr on #2233, on Emil's go. Swedish review: the 1920-1999 payee rule lived only in the routes. The cash_accounts_payee_admin_only trigger now also refuses invoice_payee on any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes it, and the backfill only targets giro/bank rows, so a company whose single enabled cash_accounts row is a Stripe clearing account keeps its legacy bankgiro in company_settings instead of landing it on 1686. pg test covers insert and update on 1686 and 1910; the function was applied to staging and probed. Typecheck ratchet: main is red from two merges that landed with failing Checks, and every branch that syncs it inherits the errors. - #2242 added POST(req) calls to the fiscal-periods route test without the route params argument withRouteContext handlers take (25 errors in the file, baseline 23). All 25 calls now pass createMockRouteParams({}). - #2247 made SyncResult.requestedFromDate and historyNarrowed required; the 13 mockedSync results in the enable-banking accounts-route test lacked them. They now carry a fixed date and historyNarrowed: false. Both files' tests pass unchanged in behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion origin/main merged 20260903183000_party_promotion while this branch held the same version. Staging's schema_migrations row must follow (pending: the Supabase MCP was disconnected at the time of this commit). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
88a5d78594 |
fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) (#2244)
* fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) A mail sent to both the +lev and +ver address of one inbox was read as its first recipient only, and an attachment whose processing threw left no row at all: the webhook answered 200, Resend never retried, and the document was gone with nothing for the user to find. Prod showed both shapes for the reporter (a +lev mail Resend accepted with zero inbox rows, and the second PDF of the +ver mail missing). - The webhook now reads every shared-domain recipient, groups them per inbox, files once per inbox with a company-scoped dedupe key, and resolves contradicting tags (+lev and +ver on one mail) to no hint so extraction classifies. - The per-attachment catch writes an error row instead of only a console line. - One InboundMailReceived behandlingshistorik event per mail and inbox records recipients, tags, hint, conflict and the outcome per attachment (filed, duplicate, rejected, failed). No sender or subject, matching the existing PII rule. - GET /inbound-history?days=30 serves those events, company-scoped, and the inbox workspace shows them under Källor as "Inkomna mejl", each filed row a click away. - The list says how many rows the type filter is hiding, with a click back to all types. - Migration 20260903190000 registers the event type and replaces the (email, attachment) unique index with (company, email, attachment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoG2CXf8B33Q5wp8gk4kW4 * fix(inbox): keep addresses and sender-typed tags out of the mail record, and let redelivery heal a transient failure Skeptic pass on #2244, two refutations: - The InboundMailReceived payload carried the recipient addresses and every plus-tag verbatim. An enskild firma's inbox local part is the owner's name, the tag is whatever the sender typed, and processing_history is append-only and outside the erasure path; a numeric tag also tripped the PII validator so the record was silently dropped. The event now carries inbox_id, the documented tags (+lev/+ver), an unknown-tag count and the outcome codes. The history route resolves inbox_id to the company's own address at read time. The DB strip trigger from 20260901110000 covers the new type (and is recreated, since staging skipped that file). - The catch-path error row made a Resend redelivery report "duplicate", so a transient download or storage failure that used to self-heal on retry became permanent. The row is marked transient and a redelivery replaces it; rejections (bad type, too large) stay duplicates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoG2CXf8B33Q5wp8gk4kW4 * fix(inbox): cap inbound fan-out, flag a truncated mail history, and name a replaced transient row Review pass on #2244: Superagent (bound the number of inboxes one mail can fan out to: five), CodeRabbit (the history route now returns has_more past 200 rows and the panel says so instead of "every mail"), and the Swedish accounting review (a redelivery that replaces a transient error row names the replaced row on the InboundMailReceived record, so the replacement leaves a trace). The migration comment states why the index swap is not CONCURRENTLY: Supabase branching applies migrations in a transaction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(inbox): resolve every addressed inbox and record the ones past the fan-out cap CodeRabbit and the Swedish accounting review on #2244: slicing recipient groups before the lookup let five unknown local parts starve a real inbox and left companies past the cap with no trace. Every addressed inbox is now resolved (one cheap lookup each), the first five are processed, and the rest get their own InboundMailReceived record with outcome fan_out_capped, shown in the panel as "not processed". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(inbox): move the inbound-mail migration past the parties versions merged tonight Main moved party_decision_undo to 20260904000100 and added 20260904000200 (#2257, #2258). A version below prod's head is skipped by Supabase branching, so 20260903190000 becomes 20260904001000 unchanged. Staging re-tracked under the new version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b07efcafd4 |
fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)
* fix(payroll): require jamkning valid_to on every write path (#2058) A jamkningsbeslut saved through the v1 API or MCP with a percentage and a start date but no end date was stored and returned 200, yet the engine (isJamkningValid) never applies a beslut without both dates: the payslip and the AGI carried the table tax while the caller believed the beslut was live. One shared validator (lib/salary/jamkning-rules.ts) now requires both dates whenever a percentage is set and checks their ordering. Every write path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST and PATCH routes, the v1 PATCH route (its private copy is deleted), the MCP create and update executors in employee-commands, and the MCP update tool preflights the merged row at staging time so the agent sees the error before approval. The update paths keep the existing touched gate, so legacy rows stored without valid_to stay editable in unrelated ways. The MCP tool descriptions state that both dates are required for the beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing rows (percentage set, valid_to null) per company, read-only; setting an end date or clearing the beslut is decided per company since either changes the next payslip. Declined: defaulting valid_to to 31 December of the from-year. It matches most beslut but silently changes withholding on rows that today do nothing. Closes #2058 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * fix(payroll): keep the jamkning PR inside the type and tools/list budgets CI on the first push failed on two ratchets this PR itself tripped: - Typecheck ratchet: the three staging tests added here reused the untyped 'agent_chat' actor literal the file already carried, which raised that file's error count above its baseline. They now pass { type: 'user' }. - tools/list payload budget: the first jamkning field descriptions on gnubok_create_employee and gnubok_update_employee pushed the projected catalog to 60 113 tokens against the 60 000 ceiling. The percentage fields keep a one-line "needs both dates or never applied" note; the date fields drop theirs. Also acts on the compliance swarm's GDPR Art.32 note: the read-only lister no longer selects employee names at all (the employee id is what the per-company decision needs), so the script touches no PII. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB * docs(mcp): say the jamkning percentage is rejected without both dates CodeRabbit on #2240: "never applied" described the pre-fix engine behaviour; the contract now is that a create or update with a percentage and a missing date is rejected before staging. Same length, so the tools/list payload budget is unchanged. The concurrency finding is tracked in #2256 instead of this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under budget after proforma landed on main After merging main (#2254 proforma fields) the projected tools/list measured 60 010 tokens against the 60 000 ceiling with this PR's two jamkning field notes. Per the budget test's own rule, demote a read tool instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes search-only. Versions exist only once a report is rendered for signing or filing, which is the same switched-off iXBRL path as its sibling gnubok_get_arsredovisning_filing_status, already search-only since 2026-09-02. Still reachable via gnubok_call_tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
34b677b02c |
chore(ui): retire the Building2 icon app-wide (#2235)
Founder request from the register walkthrough. Suppliers (nav, command palette, empty state) use Truck; company and company-scoped surfaces (active company badge, invite, home signpost, SIE preview, template scopes, TIC workspace and its manifest) use Briefcase; the two bank contexts use Landmark. The extension icon resolver no longer maps Building2; the generated sector definitions follow the manifest. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b996da60ee |
feat(parties): Förslag från bokföringen, confirmed straight into Leverantörer and Kunder (#2206)
* feat(parties): Kontakter register, suggestion queue, dossier and merge Phase 1's two surfaces on top of the parties substrate: - /parties page: one list with the five-way switch (Alla, Kunder, Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all period picker, and at most one attention line. Confirmed rows show roles as muted text, rhythm, underlag, dominant account and money. Observed rows are computed and never stored; a generic band keeps unattributed spend visible. - Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk confirm behind one dialog, dismiss on hover, undo on the toast. - Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and identities with source and count), Underlag och verifikat, Historik. - Merge dialog with a visible, swappable survivor and undo. - API: GET /api/parties, GET /api/parties/[id], POST suggest, decide, decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests). - Migration 20260903090000: decide_parties snapshots the reason it clears; undo_party_decisions reverses confirm/dismiss within 30 days; decision kind 'undo'. - The pipeline runs after SIE import and provider migration (non-blocking) so a migrant's register is full on arrival. - Nav entry under Register; sv/en strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): pass explicit interpolation values to next-intl next build's type check rejects a typed interface where the translator wants an index-signature record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): retry label on the load-failed state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): hard keys for companies without org number, readable names, look-alikes at read time - get_ledger_key_evidence dropped every document for a company whose own org number is NULL (the self check compared against NULL). Replaced in 20260903100000 with a coalesced comparison; pg test covers it. - Display names come from the printed name on documents, otherwise from the voucher text with the AP/AR prefix and supplier number removed. - Look-alike parties (same core, or one core extending the other by whole words: Fortnox / Fortnox Finans) are detected when the register is read, never stored, and feed the Dubblett? chip and the merge dialog. - Queue shows Intäkt beside Kostnad; dossier hides zero money rows and formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no synchronous setState inside effects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): link every new supplier and customer to a party on write The backfill covered the rows that existed on 2026-09-02; 108 rows created since had no party and never reached the register. A BEFORE INSERT/UPDATE trigger on customers and suppliers now calls ensure_party on every write path at once: find-or-create by org number inside the company, never by name; a private customer gets a kind=person party without any number; a nameless row stays unlinked; a foreign party id is refused with the same error as the composite foreign key; a link to a merged party follows the chain to the survivor; the clear that ON DELETE SET NULL performs is kept. ensure_party lets the trigger act for the row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The migration also links the rows created since the backfill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): dossier hides dismissed parties and follows merges to the survivor The register hid archived parties while the dossier still served them by id, and a merged party's dossier pointed at a dead row. Superagent P2 on #2206; three unit tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the role-link migration past main's 20260903110000 Two files with one version would collide in schema_migrations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun Founder decision after the walkthrough: users know two words. The page becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen' beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer views go. Each suggestion shows what it becomes (Blir), read from the ledger side and changeable per row; confirming calls promote_parties, which creates the supplier and/or customer row from the party's facts, never a duplicate, and is undoable for 30 days through undo_party_promotions (the created rows are archived, the party returns to the queue). Leverantörer and Kunder carry the one attention line that leads here. The dossier offers Lägg upp som leverantör / som kund. Migration 20260903130000, 5 pg tests, route and unit tests updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): write bankgiro and plusgiro the way the supplier form does Identities are stored as digits; suppliers carry 5317-0900. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the four queue migrations past main's 20260903170000 Main merged 20260903120000_skattekonto_transactions_realtime_publication with the same version as the role-link trigger; the preview database refused the duplicate key. All four now sit after main's newest so the set applies in one ordered run on prod. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a48508e5b0 |
feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) (#2255)
* feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) Two things from the same Discord report, both in bookkeeping from the transaction view: 1. After picking a kostnadsställe the field showed only the code ("1"). DimensionCombobox now writes the value's full name under the field once a code is committed, exactly as AccountCombobox does for the account name (looked up in the full registry so an archived code stays readable). The input text itself stays the code: the blur/revert logic keys on it. 2. A self-created dimension could not be removed at all: the DB already allowed it (enforce_dimension_registry_guards lets a non-system dimension go when no posted/reversed line carries its number, and the value retention trigger fires on the cascade), but no route or UI asked. New DELETE /api/dimensions/[id]: 400 DIMENSION_SYSTEM_DELETE for kostnadsställe/projekt, the guard's own Swedish P0001 verbatim as 409 DIMENSION_REFERENCED, 404, and a happy path; the register gets a quiet "Ta bort dimension" link for the active custom dimension behind a DestructiveConfirmDialog. Keys added to sv and en. Closes #2219 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * test: satisfy the TypeScript ratchet for two test files main inherited from #2247 and #2242 accounts-route.test.ts built SyncResult literals without the requestedFromDate / historyNarrowed fields #2247 added (vitest does not typecheck, so it passed locally); fiscal-periods route.test.ts got two more one-argument POST(req) calls from #2242 in a file already at its ratchet baseline. Both files now typecheck; the ratchet runs clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |