main
731 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a84d2723e0 |
feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import A verifikat migrated from Fortnox/Visma lost the trail of what had been corrected in the source system: the parser skipped #BTRANS (struck lines) and #RTRANS (lines added by a rättelse) and nothing else read them. The final state is still built from #TRANS only, exactly as SIE 4B prescribes (#RTRANS is always twinned by an identical #TRANS, so summing all three double-counts, #63). The two history record types now ride along the voucher as `corrections` and land, inside the same atomic import transaction, as one journal_entry_rattelse_log row per corrected voucher with source='sie_import', the file's sie_import_id and the SIE `sign` (who corrected in the source system; SIE carries who, never when). Why the problem occurred: the March fix for double-counting chose "skip" over "keep aside" because nowhere existed to keep the history. The inline rättelse log (July) created that place, and every reader of it (verifikat page, "Rättad" marker, behandlingshistorik, full archive) already renders struck/added snapshots, so the history now flows through one table. What was removed or simplified instead: no new table, no per-import toggle, no fifth RPC parameter (sie_import_id travels inside each payload entry so the (uuid,uuid,uuid,jsonb) signature, grants and statement_timeout stay put and PostgREST sees no overload). The parser's three identical TRANS/RTRANS/BTRANS field parsers collapsed into one helper; the TRANS-only ledger path is byte-for-byte the same. Why this over the proposed shape: the reporter suggested an own table or column. A separate store would need its own readers, RLS, archive classification and behandlingshistorik wiring; the rättelselogg already has all four. Storing history in sie_imports.migration_documentation was rejected as aggregate JSON that no per-verifikat surface reads. Import-sourced log rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids. Parser also warns when an #RTRANS is not followed by its identical #TRANS twin (a spec violation that would silently drop a line from the final state) and the record-type comments now match the spec wording. Migration 20260909132618: three nullable/defaulted columns + CHECKs on journal_entry_rattelse_log, sie_correction_snapshots() helper, import_sie_journal_entries body verbatim plus the history insert. No backfill; existing imports and log rows untouched. Fixes #2427 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn * fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures Review findings on PR #2458, one pass: - Superagent P2: import_sie_journal_entries stored the caller-supplied sieImportId as WORM audit provenance without checking it. The RPC now requires the id to be one of the importing company's own sie_imports rows and fails closed (42501, whole import rolled back) on a foreign or fabricated id. pg-real test added. - Compliance review: the voucher-level external_signature collapsed distinct correctors per line. Each struck/added snapshot now carries its own SIE sign (importer + sie_correction_snapshots), the summary column stays as the first one. - Compliance review: created_at on imported rows is the import moment. Behandlingshistoriken now says so in the event details instead of leaving it implicit (the verifikat page already avoided a date). Migration file is unshipped (not on main); staging re-applied under the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9a8291f454 |
feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457)
* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it Resultatrapport and dimension-pnl filtered account 8999 out and printed a computed result row, so a user who books or imports the omföring of årets resultat by hand saw huvudboken and the account-level report disagree. Why it occurred: the filter was copied from the formal Resultaträkning, where it is right (ÅRL's uppställningsform has no 8999 line). In the operational report it hid a real balance. Our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances that exist are manual or SIE-imported ones, exactly the case the report suppressed. What was removed: the exclusion itself, in both operational reports, so they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat" to match the UI and PDF. Beräknat resultat now reads zero after such an omföring, the Fortnox/Visma resultatrapport convention. Why this and not the proposed shape: the user asked about Resultaträkning, which stays as is on purpose. The bigger version (Stage 2 of #1051, showing the bokslut verifikat via exclude-final) would zero every row of a closed year given our closing-entry shape and is a separate decision; recorded in DECISIONS.md. Fixes #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t * test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning The cross-surface agreement test claimed the two operational reports are identical; after #2455 they differ by exactly a booked 8999 omföring, and the fixture had no such row so the invariant went silently false. Pin the gap explicitly, and note in DECISIONS.md that this supersedes the 2026-07-29 same-profit line. Refs #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e996d70955 |
feat(settings): rename learned counterparty templates (#2454)
* feat(settings): rename learned counterparty templates A user asked why a learned template under Inställningar > Mallar can be deleted but not renamed. Nothing legal or ledger-shaped blocks a rename; the one real obstacle was that the learn path keys templates by the normalized bank description, so a renamed row would stop receiving re-approvals and a duplicate would appear under the old key. Why it occurred: counterparty_name doubles as display name and as the learn/upsert key, and the only write path for it was the learner. There was no rename because every later approval would have forked the row. What was simplified instead of added: no display-label column, no new table, no migration. The rename moves the old key into counterparty_aliases, which the matcher already checks first, and the learn lookup (findTemplateByKey) now resolves name-then-alias so re-approvals and SIE re-imports land on the renamed row. Why this over the proposed shape: a separate label would have kept the key untouched but added a second name field for users to reason about; renaming the key with an alias trail gives the user exactly what they asked for with one fewer concept. Duplicate names are refused with 409 (active twin) or the invisible soft-deleted twin is removed (inactive). Fixes #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): resolve the normalized-name match tier through aliases after a rename Skeptic refutation on 819894559: the alias tier compares raw lowercased bank descriptors, so the normalized key a rename pushes into aliases ("spotify") never matched there, and the name tier only knew the new label ("musik"). A renamed template kept learning through findTemplateByKey but was never proposed again for the merchant it was learned from. nameMap now also resolves aliases, with a real counterparty_name always winning over another row's alias. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): canonical name beats a borrowed alias; unique-name race returns 409 Review findings on #2454: - The alias tier ran before the name tier, so a bank line that is exactly another template's canonical name could resolve to a row holding that string as a rename alias. Aliases claimed by a different template's counterparty_name are now skipped when building the alias map. - The PATCH twin check and the update are separate statements; a learn or a concurrent rename between them surfaced as 500. Postgres 23505 on the update now maps to the same 409 as the pre-check. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fc2d78a7c4 |
feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)
* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick Most people do not know their organisationsnummer. They left the onboarding for allabolag, searched their company name there, copied the number and pasted it back. #2421 let the field take a name, but only on Enter and behind a screen that still said "organisationsnummer", so the detour stayed. Now the field suggests companies while a name is typed (name, orgnr or "Enskild firma", city; arrow keys or click to pick), the pick fills the company like a typed orgnr, and the screen says "Vilket företag är det?" with "Företagsnamn eller organisationsnummer" as the placeholder. Why the problem occurred: the one identifier the step asked for is the one the user is least likely to remember, and the free-text path added in #2421 was invisible (copy unchanged) and had to be guessed (Enter only), because the only search index behind it was TIC, whose Lens budget cannot take a call per keystroke. What was removed or simplified: nothing is stored and no new state model: a picked suggestion is an ORG_SUBMITTED with prefill, so the existing LOOKUP_RESULT transitions (found, not found, disabled, error) decide the step exactly as for a typed number. SCB's name search already existed for the parties picker; it gained one option (sole traders) instead of a second client. No rate limiting anywhere, per the founder. Why this shape: SCB's företagsregister is free and already configured for the parties picker, so search-as-you-type costs nothing while typing; TIC runs once, on the pick, as it always did on Enter. TIC per keystroke was rejected (3000/month). SCB alone was rejected for the pick because it knows no F-skatt, VAT registration or fiscal year. The Enter path and the chip row from #2421 stay as the fallback when no row is picked. Sole traders are offered (they are half the users) but their row names the form and never prints the personnummer, and the field shows the company name after a pick for the same reason. Changes: - app/api/company/search: GET ?q= over the SCB client with sole traders included, top 6 rows plus a truncated flag; requireAuth() (no company yet), 400 for short or numeric q, 503 without SCB credentials, 502 when SCB does not answer. - lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }), legalFormCode on every candidate; the parties picker is unchanged. - lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX, fetchCompanySuggestions (503 is disabled, everything else error, never throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC vocabulary mapSetupEntityType reads). - lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and form as prefill, lookupPending; lookupRan stays false until TIC answers). - components/onboarding/journey: 300 ms debounced SCB search with abort of the superseded request, listbox under the field (combobox ARIA, arrow keys, Escape, Enter picks the highlighted row, otherwise the Enter path), copy switches with companySearchEnabled or ticEnabled; both journey pages pass isScbConfigured(). - messages sv+en: five strings. Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC, TIC overrides prefill, TIC off keeps the AB past form and name, unmapped form falls to the picker, sole trader confirms the name, replaces a previous orgnr, ignored off-step); SCB client sole-trader option. Fixes #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi * fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c) Three independent refuters on the frozen commit; every refutation that stood is fixed here. - The listbox was position: absolute inside the field, but the step scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to the first row and mouse picks were unreachable (measured in headless Chrome). It now renders in flow under the field, where the chip row from #2421 already lives. - After Enter on a name (the #2421 path), SEARCH_RESULT flipped lookupPending back and the debounced effect refetched SCB, laying the listbox over the chip row or next to the nomatch note. The effect is now quiet while searchHits is non-empty and for text the user already confirmed (Enter or a pick), until the text changes. - The "many matches, type more" hint only rendered inside the list, so the flood case (SCB counts over 100 rows and sends none) showed nothing. The hint now renders on its own for that case. - app/companies/new-client (byrå adds a client) renders the same journey and now passes companySearchEnabled like the other two pages. - A stale mouse highlight could commit a row from the previous text on Enter: typing resets the highlight. - Any 503 switched the picker off for the session; only the route's own SCB_NOT_CONFIGURED does now. - NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the abandoned pick's name and form, which a later TIC error path would have written into the company. Both now drop name and form too, unless they came from BankID's CompanyRoles prefill, which is not about the number. Not changed, recorded: a sole trader picked from SCB whom TIC does not know lands on the "no company on that number" step with the name in the field; the flow continues with the SCB name prefilled. The search JSON carries the personnummer of sole-trader rows to the authenticated browser (the row prints "Enskild firma"), same class as #2421's Enter search; flagged to the founder. Refs #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi --------- 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 |
||
|
|
a7dcaac6ad |
feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)
The KPI payload has carried income, expenses and net per month since the aggregates RPC, but after the Recharts trend chart was dropped only the net column was rendered (the bars pane). A fiscal year's month-by-month sums were therefore fetched and never shown (#2196). - New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad, Intakter, Kostnader, Resultat) with the period totals as the last row, rendered between the panes and the cost story. Rows and totals come from the pure helper components/kpi/months-table.ts. - New preference showMonthlyTable (default true) on KPIPreferences: filled by mergeWithDefaults on read, accepted by the preferences route, sent whole by the dialog, required by readPreferencesBody. A boolean, not a KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every existing company. - One Switch row in the Anpassa dialog after the KPI list. - Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total and the two settings keys in sv and en. - Tests: helper rows/totals/inactive flags, defaults + merge, route accepts false and rejects a string; fixtures updated for the new field. Closes #2196 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> |
||
|
|
32721b9f61 |
feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)
* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner Why the problem occurred: the draft marking was a boxed yellow banner in the page's top margin. It stayed out of the flow (#2369) but still read as UI chrome pasted on a document, and carried a two-line legal sentence that nobody reads on a preview. What was removed: the banner block, its three styles and the four legal sentences (sv+en). The draft state is now one word, bold, rotated -35deg at 14% opacity, centred on every page, the way a stamp marks paper. The download dialog (#2399) already explains why a draft is not a valid invoice before the file exists, so the PDF does not repeat it. Why this shape: rotation and opacity sit on a padded wrapper View so the word turns about its own centre and the Text keeps a plain type style. The overlay is absolutely positioned over the page box and `fixed`, so the document underneath previews pixel-identical to the final print; a test asserts the first row sits at the same y as on a sent invoice. BETALD and MAKULERAD banners are unchanged (separate concern). Fixes #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(invoices): darken the draft watermark so it survives a greyscale print Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on white, which a monochrome print or greyscale scan drops, and a numbered draft otherwise prints the FAKTURA title, its number and an OCR like an issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85% so neither extreme can creep back in. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): paint the draft watermark last so opaque boxes cannot cover it Skeptic refutation (correctness and regression, independently): the overlay was the first child of the Page. react-pdf paints children in document order and `fixed` does not hoist, so the customer box and the full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the word. On a two-page draft the last page, the one with totals, bankgiro and OCR, lost the word entirely. The overlay is now the last child of the Page, behind a single isDraftMarked flag that also keeps the cancelled > draft > paid banner precedence. A new test inflates the rendered PDF content streams and asserts the UTKAST glyph run comes after the last rectangle fill on every page, so the element tree alone can no longer pass while the paint order is wrong. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(invoices): note the English DRAFT label in the download-decision comment CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an English document says DRAFT. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2f787a2b3c |
fix(bookkeeping): send each missing-underlag lookup chunk once per URL (#2430)
* fix(bookkeeping): send each missing-underlag lookup chunk once per URL The "verifikat utan underlag" filter (/bookkeeping?missingUnderlag=true) failed with "Verifikaten kunde inte hamtas" on a self-hosted instance as soon as the candidate set passed one chunk of 150 ids. Why it occurred: resolveMissingUnderlagEntries issues four lookups per chunk. Three carry the id list once; the supplier_invoices lookup interpolated the same chunk twice into a single .or() over registration_journal_entry_id and payment_journal_entry_id. That URL alone crossed the 8 KB header buffer nginx/Kong ship with, so the gateway answered 414 before PostgREST saw the request. Hosted sits at roughly half of Cloudflare's 16 KB ceiling on the same query. The LOOKUP_CHUNK docblock acknowledged the doubling without sizing for it. What was removed: the runtime-built .or() string, the chunkInList helper and its uuid guard (the .in() array filter is injection-safe on its own). The supplier-invoice lookup is now two .in() queries, one per FK column, merged into the same set, so every request carries the chunk exactly once and the proxy limit stops being a dependency rather than moving. LOOKUP_CHUNK stays 150 and its comment is now true. The literal filter also leaves the phantom-column scanner's unresolvable budget. Two secondary defects from the same report: MissingUnderlagQueryError now carries the raw driver error as `cause` and the journal-entries route logs it, while the response keeps the Swedish text (the log used to say only "Nagot gick fel", hiding the 414). The "Visa saknade underlag" badge renders the total of the last successful filtered fetch and hides on failure, instead of borrowing the list count (0 on a failed first load, the whole ledger after a toggle). Alternatives: halving LOOKUP_CHUNK moves the wall instead of removing it. Pushing the list filters into the verifikat_without_documents RPC and deleting the TS mirror leaves one predicate instead of two, but moves search, series, date and sort into SQL; recorded in DECISIONS.md as the intended next step for the surface owner. Fixes #2395 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ * fix(bookkeeping): hide the underlag badge on network failure, log the bulk route's cause Skeptic findings on aacb17634. The badge contract is "no honest source, no badge": the non-OK branch cleared missingCount but the network-level catch (offline, aborted body, JSON parse rejection) did not, so a period or series change that failed at that level kept the previous filtered total next to the toggle. The bulk "Inget underlag kravs" route had the same log gap as the list route: it returned the mapped text without logging the driver error, so a gateway 414 on that path stayed invisible. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ --------- 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> |
||
|
|
e85eac317c |
feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* feat(arsredovisning): manual override for medelantal anstallda (Not 2) Why the problem occurred: the ÅRL 5:20 § note was derived only from the employees table, and most aktiebolag that book salary never create a Löner employee record (hand-booked salary, SIE import, migrated history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have no employees rows, so their note reads "inga anställda". The reporting company had one row created the same day with a start date halfway through a July-June year: 181/365 = 0.5 FTE rounds to 0. What was removed or simplified: nothing removed. One resolver (resolveMedelantalAnstallda: override, else FTE average) now feeds the K2 note, the K3 note and the iXBRL fact, so no reader can pick a different number. The override sits on arsredovisning_narratives next to the other ÅRL 5 kap. disclosures and rides the existing narrative GET/POST route, service and page save. Why this and not the proposed one: the request asked support to "enable override of Not 2" as free text. A whole number keeps the statutory sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free text would allow a non-compliant note. Rounding 0.5 up globally was rejected (changes every company's note silently, does nothing for companies with no employees rows), as was backdating the hire date (fixes one company, misstates the fact). The iXBRL input also reads the previous period's override so the jämförelseår column shows what last year's document showed. Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and is applied and tracked on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD * fix(arsredovisning): size-threshold metrics use the medelantal override too Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read the FTE average from the employees table, so the ÅRL 1:3 § större- företag test and the K2 relief thresholds could disagree with the figure Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no employees rows and an override of 60 both years, balansomslutning over 40 MSEK, would have validated as K2-eligible while its own document said 60 employees. Fix: the metrics resolve the employee figure the same way the note does (current period override from report.disclosures, previous period via getMedelantalOverride on that period's narrative row). previous_period on ArsredovisningData now carries the period id so the lookup needs no extra fiscal_periods read. The iXBRL employees-error fallback keeps the previous period's override instead of blanking the jämförelseår. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD --------- 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
9879fb53e9 |
fix(billing): return from Stripe to the brand domain the user started on (#2370)
Checkout success/cancel URLs and the customer-portal return URL were built from NEXT_PUBLIC_APP_URL, so a user on a white-label host came back to the canonical app, where they hold no session, and saw a foreign-branded login. Both routes now resolve the request host through resolveRequestAppOrigin: a registered white-label host stays on its brand, anything else falls back to the canonical app. Return paths stay fixed literals. Claude-Session: https://claude.ai/code/session_01DAGcgQDEAGmhGNSeMbgsn2 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
bf7773d74b |
feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)
* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184) A new company_settings row now defaults to the standard series set (A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor, E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura, M moms) instead of everything on A. The set lives once, as the exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test holds the column default equal to it and to the source_type CHECK. Existing rows are not remapped: the per-type settings form gets an "Använd standarduppsättningen" action that fills the set for review and save through the existing PUT, so the switch is a deliberate, audited act rather than a mid-year numbering change nobody decided. Payment rows bound to the other bokföringsmetod are dimmed, not hidden. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A voucher-series-defaults.pg.test.ts codified the pre-#2184 column default (every source type on A). Migration 20260906210500 replaces that default with the standard set, so the "freshly inserted row" case now asserts the representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP. The explicit-override case keeps proving a company's own layout replaces the default wholesale. No other pg or tool test asserted on the old map. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> 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> |
||
|
|
4fce2d7b94 |
feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 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> |
||
|
|
0069a3f59a |
feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran Skatteverket decides per begäran but pays everything it decided that day in one transfer, so the bank row often equals no single open begäran and the 1:1 matcher from #2271 stayed silent; the settle service then refused the amount and the dialog told the user to split the transaction by hand. The candidate is now the exact covering set of 1..4 open begäran whose expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts, over the existing findExactCoveringSet, ambiguity-refusing). It is computed at read time from the open pool (inbox page, worklist, ingest), no hint column. Confirming books ONE voucher (debit 19xx, one 1513 credit per begäran) through the same writer, marks every begäran paid and links the row once; a bundle is always booked at exactly the decided sums. - match-rot-rut-payout accepts request_ids (1..10) beside request_id - settleRotRutPayoutRequestSet shares the single path's tail - createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged) - inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set - new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set Closes #2239 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle The bundle path booked every leg as fully paid and mirrored requested_amount onto every begäran's items, while the single path completes a begäran only when the leg covers requested_total and otherwise leaves it partially_paid for manual handling. A begäran Skatteverket decided at less than requested is a legitimate bundle member (its leg is the beslut, the exact-sum rule is unchanged), so the set path now computes fullyPaid per leg exactly like the single path, passes it to the shared attachSettlementVoucher, and mirrors only the fully paid legs; sibling hints are still cleared for every settled begäran, which carries a voucher and is no longer matchable either way. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 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> |
||
|
|
6a5fd6cd00 |
feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)
A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.
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>
|
||
|
|
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> |
||
|
|
ebbe50c0f3 |
feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)
The supplier-invoice form asks who paid with the same control as the Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead of its own switch under Förval. A person paying is an utlägg: the route hands the invoice to registerExpenseClaim with the invoice's kontering as the claim's lines, so the verifikat and the expense_claims row come from the same writer as the Underlag pane, the person shows up under "Betala ut utlägg" on Hem and the bank matcher closes the debt. Employees book on 2820 with employee_id; the owner's blank name falls back to the shared label so Hem groups one person. Also routes a person-paid inbox document through the core route with inbox_item_id: the extension's convert endpoint never read paid_with_private_funds, so the old switch was silently dropped whenever a receipt was attached. The second entry generator, the Förval switch, the outline "Registrera & markera som betald" button and the duplicated owner/employee picker are removed; PayerChoiceSelect and the claimant fields move to components/expenses so core and the extension share them. Closes #2332 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> |
||
|
|
fcda4a75dd |
feat(parties): fill customer and supplier forms from the register on a valid org number (#2355)
The registry lookup was built for rows that already exist (the detail page's "Hämta uppgifter" records facts on the row's party), so on the create form people typed what SCB already knew. Org number now comes first in both forms; a complete, check-digit valid number of a Swedish legal person is looked up once and fills name, address, postal code, city (and the VAT number where the form shows one) wherever nothing has been typed. A typed value is never replaced; a corrected number replaces only its own earlier fill. A personnummer never reaches the register (client key and server gate), and an environment without SCB credentials answers 503 once and the form stays quiet. - GET /api/parties/registry?org_number=: read-only route over the same SCB client, credential gate and registrySummary reader as the enrich route; writes nothing. - lib/parties/registry-form-fill.ts: registryLookupKey (one rule for what may be looked up) and registryFormFill (contactFill's untouched rule plus name and VAT number), pure and tested. - components/parties/use-registry-autofill.ts + RegistryAutofillNote: debounced, once per distinct number, skips the number an edit dialog opened with, one muted line under the field. - Adressrad 2 is now an input on both forms: the register's c/o goes on line 1 with the street on line 2, as on the row, and both edit dialogs already passed the column in. 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> |
||
|
|
3b9daf2606 |
fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the payout RPC applies, so "Jakob" and "jakob " are one person with one Att göra row and one exact-amount match. - The inbox pages through every registered claim (fetchAllRows) before pairing, so a long backlog can never understate a person's debt. - A foreign receipt's VAT field is locked at 0 and 0 is what is submitted. - Transport failures in the one-click and picker confirms show the destructive toast instead of failing silently. - The open-claims flag is set only after the stale-fetch guard, and a payout match decrements the inbox count like every other row exit. - Test: reset the live-link mock before the bank_line junction case. - Wording: "Återbetalning av utlägg". 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> |
||
|
|
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> |
||
|
|
ea4da0eb07 |
fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)
Part of #2059 (Part 2, the hardening bug). The FK on invoice_items.article_id proves the article exists, not that it belongs to the writing company: FK validation ignores RLS, and the v1 routes run on the service-role client with no RLS at all. Only the two MCP commit executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and sales-order writers passed items[].article_id straight through the builder. Move the check to the one point every writer converges on: buildInvoiceWriteData collects the distinct article ids from product lines, runs one select scoped on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400, Swedish message via the structured-error registry) on any miss. The MCP executor checks stay as the tamper gate for staged rows. Tests: builder unit cases (miss refused with details, dedupe + happy path, no article ids means no query, DB error surfaces as dbError), cookie PATCH and v1 POST refusal cases, and the existing v1 persist + MCP update tests now answer the builder's scoped select. 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> |
||
|
|
b71f2bf425 |
feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line
The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).
- create_expense_payout_batch(..., p_transaction_id): old signature dropped
so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma
- "Matcha mot utlägg" in the inbox row menu: pick the person and the
receipts a transfer covers when the exact-amount pairing missed it. The
picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
from Att göra, the attention resource, suggestions and the picker; a
payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
the dialog says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
|
||
|
|
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> |
||
|
|
0c854ac54f |
feat(settings): let a company name each verifikationsserie letter (#2336)
* feat(settings): let a company name each verifikationsserie letter
The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.
- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
letter is called: company name, then preset, then empty.
buildVoucherSeriesOptions replaces the three near-identical option
builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.
Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): keep imported series in the list and unsaved names through a refetch
Skeptic pass on the series-name editor refuted two things:
- The rewritten list filtered voucher_sequences to single letters, dropping
multi-character series (FT, LB, SKV, ...) that 54 production companies
carry over from Fortnox and Bokio imports; the old list showed them with
their highest number. Rows are now every used series plus the configured
and named letters; only single-letter series get a name input, since
those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
and the settings hook revalidates on window focus with a fresh object, so
unsaved typing was wiped after any earlier save on the page. The re-seed
is now keyed on the serialized content of the saved names.
Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): name the default-series options and enforce the label shape in the database
Review pass on #2336:
- CodeRabbit: the Standardserie selector under Bokföring still rendered
bare letters; it now shows the same name the other pickers do, through
voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
voucher_series_labels lived only in UpdateSettingsSchema. Migration
20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
non-blank strings of at most 40 characters), so a write that bypasses
/api/settings cannot store a map the pickers cannot handle. Applied to
staging with its schema_migrations row; verified against good, empty,
lowercase, blank, over-long, numeric and array inputs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* test(pg): cover the voucher_series_labels CHECK against real Postgres
The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
---------
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> |
||
|
|
f33628f005 |
feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along The preview scored the file's accounts against the BAS reference and said "matchas mot din kontoplan", so a consultant with a 41-account seeded company read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap with a non-empty period was only refused after the mapping step. - Parse route adds preview.chart (accounts new to THIS company vs already present, with a sample) via planChartChanges, and preview.fiscalYear from precheckFiscalPeriod: the containment/overlap verdict extracted out of ensureFiscalPeriod, which now consumes it, so preview and import cannot drift. - Preview card renamed to Kontoplan with the counts and the fiscal-year verdict (match / create / conflict with the import's own refusal text). - Review step lists the chart among "Vad händer när du importerar?". - executeSIEImport reports accountsCreated from the account sync; the result grid gets a Konton skapade card. No import logic changed; no migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC * fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton" Skeptic pass on #2307 refuted the first cut twice: - "Skapas vid import" was shown for a #RAR the import then refuses under BFL 3 kap. (19 months, non-month-end finish, mid-month start after an earlier year). The shape rules move into precheckFiscalPeriod as a fourth verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a consumer of one verdict, same query order. - The Kontoplan card counted unmapped sources under "Läggs till" and kept listing them after the create button, while the result said 0 created. planChartChanges (now client-safe in lib/import/chart-plan.ts) counts mapped targets only; the create button moves those accounts from "Ej mappade" to "Finns redan" in place. - The review line claimed existing accounts keep their name unless you opt in; the switch defaults to on. Reworded to match. - Sample names follow the file for identity mappings, as the sync does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
04898c3178 |
feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)
Founder test on a real company (2026-09-05): a supplier created from "Webhallen Oktober · Dataskärmar till kontoret" kept that text as its name, and the SCB facts fetched for the party were nowhere on the supplier page. - Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name, org number, VAT number, country, then the SCB facts under one source line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one action. The registry helpers move out of the dossier into RegistryFacts so the three surfaces share them. - The enrich route makes the registry's legal name the displayed name of the party and of supplier and customer rows that still carry the party's old name; all-capitals names are set in title case (lib/parties/registry-name.ts). Names a person set stay. - legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps its vouchers, so a rebuild attaches the new key instead of offering the same company again. - GET /api/parties/[id] reports whether SCB is configured. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
397a3b9bca |
feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143. |
||
|
|
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 |
||
|
|
287828a850 |
fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300)
* fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9418de585f |
fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)
* fix(invoices): say what is missing when an invoice preview cannot be rendered The PDF route already refuses with a structured envelope that names exactly what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a SEK invoice; no IBAN account for a foreign currency) and where to add it. Two clients threw that away: - The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa faktura) wrapped the envelope's inner object in new Error(), which stringified it to "[object Object]" and left only the generic "Kunde inte hantera fakturan. Försök igen." fallback. The parsed body now goes to the error mapper whole, with the invoice context and status. - The invoice page's Förhandsgranska navigated a new tab straight to the re-render URL, so a 400 showed the raw JSON in that tab. The tab is now opened blank inside the click's activation window, the PDF is fetched first, and the tab gets the PDF as a blob URL or is closed again with the refusal in a toast. The archived delivery copy keeps the direct open. Ladda ner on the same page had a fixed "Kunde inte generera PDF" for re-render refusals and now maps the body the same way. Regression test on the mapper covers the exact call shape the two surfaces use and pins the old mangled shape as the fallback it produced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA * fix(invoices): probe the PDF route before opening the preview tab Resolves the review findings on the first push in one pass. Skeptic (correctness): the archived-copy branch still called window.open with 'noopener', which returns null by spec even on success, so every successful archived preview also fired the "popup blocked" toast (#1613 had the same defect). Both branches now go through openDeferredTab, which opens with a real handle and severs the opener itself. Skeptic (regression): serving the re-render as a blob URL lost the Content-Disposition filename and gave the tab an address that dies on reload. The route gains ?probe=1, which runs every refusal check and answers 204 without rendering; the page probes first, shows a refusal as a toast, and otherwise points the tab at the real inline URL. Filename, reload and the single render are all kept. The blob URL is gone, which also settles the compliance swarm's noopener and unrevoked-blob notes and CodeRabbit's revoke request. CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled route cannot leave a blank tab open, and the network-error mapper now receives the active locale and invoice context. Tests: route probe (204 without render, same 400 envelope as the render, unknown value ignored) and the URL helper's probe flag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA --------- 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> |
||
|
|
7c36d471b5 |
fix(sandbox): use posting engine and recover failed seeds (#2297)
* fix(sandbox): seed through posting engine and recover failed attempts * test(sandbox): align CI auth schema for anonymous users |
||
|
|
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> |
||
|
|
e684606b23 |
fix(supplier-invoices): a credit note is never a payable, so it never waits for attest (#2289)
* fix(supplier-invoices): a credit note is never a payable, so it never waits for attest A supplier credit note created with Kreditera was inserted at 'registered', the attest entry state, while the detail page (rightly) offered no attest for it. The worklist counted every 'registered' row, so Att göra showed "1 leverantörsfaktura att attestera" that nobody could clear (support case 2026-09-04; 14 such rows on prod plus one MCP-approved credit note). - One row builder (lib/supplier-invoices/credit-note.ts) for the dashboard route, the MCP executor and the v1 API: the credit note rests at 'credited' from birth, the status the provider importers already use. - CHECK supplier_invoices_credit_note_not_payable keeps every writer out of the payable states; migration backfills the stuck rows (one immutable reset-source row skipped, hence NOT VALID). - The worklist attest count excludes credit notes explicitly. - GET /api/supplier-invoices/[id] hydrates credited_original with a second scoped query: PostgREST cannot pick a direction for a self-referencing embed hint and returned the one-to-many side (an empty array), which the page rendered as "Krediterar: Ankomst #" with no number. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * test(supplier-invoices): type the GET route response in the credited_original tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion A NULL source_company_id in the subquery would make NOT IN never true and silently skip the whole backfill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * test(schema): raise the unresolved-payload ceiling by 3 for the credit-note row builder The three credit-note creation paths now insert the row from one builder, so the scanner sees three dynamic payloads instead of three literals. The columns are the literal in lib/supplier-invoices/credit-note.ts, pinned by its test; the resting status is additionally held by the DB CHECK. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * test(pg): credit-note fixtures in the overdue-cron tests rest at credited The two fixtures that seeded a credit note on 'registered'/'overdue' now violate supplier_invoices_credit_note_not_payable. The cron test seeds the credit note the way the routes create it since 20260904190000; the 20260607 backfill case asserts the scenario it repaired is now unreachable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * test(pg): ledger-usage-stats seeds its credit note at credited The fixture defaulted every row to 'registered', which the CHECK supplier_invoices_credit_note_not_payable now refuses for a credit note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE --------- 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> |
||
|
|
a870c7f03e |
fix(import): attach underlag by the basename of a folder-picked upload (#2288)
A folder-picked Fortnox export failed 50 of 50 attaches with UNDERLAG_REF_MISMATCH although the preview had matched every file. The preview is built from File.name, a bare filename by spec, while the attach route read the multipart filename, which Chrome fills with the folder-relative path for folder selections (2026/06/Leverantorsfakturor/A166_x.pdf). The guard that requires a file to land where the preview said compared the previewed basename with a path the parser cannot read, and refused. The route now reduces the multipart filename to its basename once, at the boundary, before the resolver check and before archiving, so the archived file_name is the name the user reviewed rather than a path. The parser keeps its no-directory-stripping rule: the manual-reference box shares it, and a typed 2024/01/31 there is a date, not voucher 31. Both separators are stripped; nothing else is normalized. Claude-Session: https://claude.ai/code/session_014uwXchJvF5YMgz8vRfuxLe Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
743e3ae7cc |
fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277)
* fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments The dashboard match-invoice route, its v1 twin and the pending-operation match_transaction_invoice executor wrote invoice_payments.amount as the cash received in invoice currency. When a whole-krona bank line settles an öre-carrying remaining (the customer pays the rounded "Att betala"), planInvoicePayment advances paid_amount by the remaining only and books the öre on 3740, so the row exceeded the receivable by the absorbed öre: remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60 paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with negative scaled moms, the historical AR ledger showed -0.40 outstanding on a paid invoice, and a storno of the payment voucher restored paid_amount 0.40 off (issue #2250). PR #2236 defined the amount for the manual, MCP and Stripe paths as the amount APPLIED to the invoice (new paid_amount minus the prior one). The three bank-match paths now share that definition through one helper, appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which recordInvoicePaymentRow() uses as well. Every other field of the row (payment date, currency, exchange rate, journal entry, bank transaction, notes) is unchanged. Without a residual the applied amount equals the cash received, so ordinary matches post identical rows; cross-currency rows are now öre-rounded like paid_amount instead of the 4-decimal spot conversion, so row and paid_amount agree. Existing rows carrying the overshoot are not repaired here; that is a separate call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u * refactor(invoices): one writer for invoice_payments rows Rework of the #2250 fix from first principles. The bank-match paths did not just get the amount wrong; the class of bug is that invoice_payments rows were hand-built at five product sites (dashboard bank match, its v1 twin, the pending-operation match, the link-to-existing-voucher flow, and the #2236 paths through the helper), each computing its own fields with no single definition of what the row means. recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the one writer. Its options grew by what the bank paths set, all optional with today's defaults so the #2236 callers are unchanged: transactionId (default null), exchangeRate (the rate actually used; omitted = invoice.exchange_rate, explicit null stored as null) and notes (default null). The failure result carries the Postgres SQLSTATE so the routes keep mapping a unique violation (23505) exactly as before. The applied-amount formula is an internal detail of that file again. Routed through the writer: app/api/transactions/[id]/match-invoice, the v1 match-invoice twin, commitMatchTransactionInvoice in lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts (strict plan, same currency only: its amount is unchanged, it now shares the row semantics). The pending-operation path used to drop the insert error on the floor; it stays non-fatal but is logged with ids. Guard: scripts/checks/no-new-antipatterns.mjs gains direct-invoice-payment-insert, a file-set rule with no baseline (0 today): .from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or extensions/ outside lib/invoices/invoice-payment-row.ts fails npm run check:guards. Operator scripts under scripts/ are out of its scope on purpose. Tests: the writer's unit tests cover the new options, the explicit-null rate, the SQLSTATE passthrough and the öre-rounded prior-paid subtraction; the per-path 3740 tests from the first commit stand; mock insert slots now return the row id the writer selects back. 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> |