c90ccc9c429277299fd4ecec0652cd8b6fa080be
163
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
2d49a81508 |
fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)
* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts A supplier-invoice item with a negative line_total (an öresavrundning row on 3740, a rabatt row) was copied straight into debit_amount, producing a line like "3740 debit -0.25". The entry balances arithmetically, so no trigger fired, but the verifikat page renders only positive amounts: the row showed empty and the visible debits (20 056,25) disagreed with the summa (20 056,00). Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1 customer invoice (3004 credit -0.50), 1 storno mirroring a bad original. Why it occurred: the "one non-negative side per line" invariant lived nowhere. Zod allows negative items (they are legitimate), the engine only checked balance, and journal_entry_lines had no CHECK. Any producer that aggregates user rows could repeat it. What was removed or simplified: no new state. The privately-paid supplier path already flipped negative buckets to credit; that rule is now one helper (lib/bookkeeping/line-side.ts) shared by the supplier registration, cash-method and privately-paid generators and by the customer-invoice per-rate generator. The credit-note generator stops swapping sides and takes |net|, since its inputs now arrive on the correct side. Why this and not the proposed fix: patching only the supplier generator leaves MCP, templates and future producers free to repeat the class, and rejecting negative items at input would break real rabatt/avrundning rows. So the sign is fixed at three levels: producers flip the side, the engine refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT, Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new rows regardless of the writer. reverseEntry swaps on the net so a legacy negative line stornos into a well-formed line before the data repair runs. The 14 existing prod lines are repaired by a separate founder-approved SQL (flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows in a later migration once prod reports zero offending rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9 * fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side Skeptic findings on ab119d6ed: 1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a separate revenue account) now lands that row on the debit side, but the 1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount, so the entry was overstated by the row and threw "Verifikationen balanserar inte". Both anchors now use credit - debit. EUR test added for both paths. 2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets copied bucket.amount into debit_amount and the aggregated liability into credit_amount. A negative month (unpaid leave beyond gross) produced 7510 D -628,40, which the engine now refuses. Buckets and liabilities go through debitNatural/creditNatural so a negative month books 7510 K / 2731 D. Test added. 3. replaceOpeningBalanceEntry, the third engine write path, now runs the same non-negative guard as createDraftEntry and updateDraftEntry. 4. The credit-note comment claimed |net| is side-correct for every original; it is not for originals with a negative row (pre-existing, callers negate items with -Math.abs). Comment now states the actual behaviour and the known gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero CodeRabbit on #2439: - JournalLineNegativeAmountError was not registered in isBookkeepingError / bookkeepingErrorResponse, so the journal-entry routes would have returned a generic 500 instead of the structured 400 with code and details. Added, with a test. - The three supplier balance anchors (2440 on registration, the payment account under kontantmetoden, the liability account for privately paid invoices) were fixed-credit lines. An invoice whose rows net below zero (a leverantörskreditfaktura keyed in as an invoice) produced a negative credit there, which the engine now refuses. The anchors go through creditNatural so such an invoice books 2440 D, as a supplier credit note would. Tests for all three paths. 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> |
||
|
|
59d5b7b366 |
fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts (#2419)
* fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts A sole trader booking a tractor activated 1240 and 1249 from the account picker and got a machinery head labelled "(Fritt konto för Maskiner och andra tekniska anläggningar)" next to a contra account labelled "Ack. avskrivningar på bilar och andra transportmedel". Why it occurred: BAS 2026 restructured kontogrupp 12. Bilar and datorer moved under 1210 (för produktion) and 1220 (ej för produktion), and 1230/1240/1250/1260 became free heads. The catalog in lib/bookkeeping/bas-data/ followed for the heads (#463) but kept seven sub-accounts the official chart no longer has (1241, 1242, 1249, 1251, 1259, 1261, 1269) with their pre-2026 names. Every picker activation of a 12xx contra account therefore produced the contradiction; prod carries the 1240/1249 pair in 173 charts, 1250/1259 in 153 and 1260/1269 in 78. What was removed instead of patched: 1241, 1242, 1251 and 1261 leave the catalog entirely (bas.se BAS 2026 v2 has no such accounts; the SIE mapper already self-maps unknown sub-accounts by number). 1249/1259/1269 stay because the asset module's vehicle and computer defaults and 31 live assets in prod depend on them; they are renamed after their heads so the pair reads as one thing. Why this and not the proposal: the reporter asked for 1249 to be renamed to "ack. avskr. maskiner", which fixes one number and leaves 1259/1269 and the four retired asset accounts contradicting their heads. Dropping 1249/1259/1269 and moving the asset defaults to BAS 2026 (1226/1224 on 1229) is the right long-term shape but changes what a new vehicle or computer asset books to; that decision is the founder's and is tracked in #2414. The migration renames a contra account only when its name is byte-identical to one of the two catalog literals AND the company's head carries the BAS 2026 free label, so old-BAS imports (1240 "Bilar och andra transportmedel") and every user rename stay untouched. Applied and pg-tested on staging. Fixes #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd * fix(bookkeeping): skip 12xx contra accounts with journal lines in the label backfill Skeptic refutation: lib/import/account-sync.ts creates missing accounts with the catalog name when the SIE #KONTO names are not carried, so an old-BAS vehicle chart can hold the exact free-head + bilar-contra pair with years of depreciation booked on 1249 (8 such charts in prod). The backfill now also requires that the contra account has no journal lines: a label with history is the user's to change. Migration re-issued under a fresh version, applied and pg-tested on staging. Refs #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd * fix(bookkeeping): lock journal_entry_lines while the 12xx label backfill checks history CodeRabbit (Major): under READ COMMITTED a posting could commit between the NOT EXISTS history check and the rename. A SHARE lock on journal_entry_lines for the migration transaction makes the two atomic; inserts wait milliseconds, reads are unaffected. Migration re-issued under a fresh version, applied and pg-tested on staging. Refs #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
eea410c42b |
fix(bookkeeping): correctEntry moves bank anchors to the correction; deleting the correction returns them (#2406)
* fix(bookkeeping): correctEntry moves the original entry's voucher links to the correction Why the problem occurred: a bank row has two anchors, the pointer column (transactions.journal_entry_id) and the transaction_voucher_links junction (bulk-book writes a bank_line row beside the pointer for N=1 and as the only anchor for a samlingsverifikat with N>1). correctEntry re-pointed only the pointer, so the reversed original kept its junction rows and every junction reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC, the reconciliation bridge) went on treating the row as anchored there. A later storno of the correction released the pointer while the stale link kept the row out of Att bokfora: the split #2061 fixed on the storno path, reproduced on the correction path. Prod holds 7 such links in 3 companies. What was removed or simplified: nothing new is added to the data model. The relink helper now moves both anchors with the same predicate (company and source entry), so the junction follows the pointer and one rule covers the N=1, samlingsverifikat, 1:N slice and residual shapes. A relink failure is surfaced on the result (transactionRelinkError) beside documentRelinkError instead of being logged and forgotten. Why this solution: the issue proposed deleting the original's junction rows. For a samlingsverifikat the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into the worklist; re-pointing keeps them booked against the live entry. A relink_entry_anchors RPC moving pointer and junction atomically was considered and left for later: it costs a migration plus pg test on a path that is already best-effort across five other statements, and the surfaced warning now makes a partial failure visible if one ever happens. Tests: unit cases on correctEntry for the junction update, its scoping and the surfaced warning; a pg-real suite that runs the two UPDATE statements as the correcting user against real Postgres for the N=1, samlingsverifikat, split-plus-residual and cross-tenant shapes (RLS, the writer-role gate and the immutability triggers do not block the move; role and allocated_amount survive it). Fixes #2364 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * fix(bookkeeping): deleting a correction returns its bank anchors to the original Found by the skeptic on the previous commit. Once the junction follows the correction, the two-step undo of a rattelse (delete the correction, last in series, then delete the storno, which restores the original to posted) cascaded the links away with the correction (FK ON DELETE CASCADE; the pointer FK is ON DELETE SET NULL). The restored original then explained bank rows nobody pointed at: is_business stayed true, the rows surfaced as bookable in Att bokfora and in bank reconciliation, and a second booking of the same movement was one click away. Before, the links had stayed on the original by accident and the undo happened to be clean. delete_last_voucher (migration 20260908095907) now moves both anchors back to correction_of_id before deleting a correction, the inverse of the move correctEntry makes. Releasing the rows instead would leave the same trap (the restored original still explains them), and a TS pre-step in the DELETE route is not atomic with the RPC's own guards. A link the original already holds (a correction made before the junction followed it) is dropped rather than duplicated. Everything else in the function is byte-for-byte 20260528120600. Applied to staging and recorded under the file's version. pg-real suite covers the N=1, samlingsverifikat, pre-existing-duplicate and plain-voucher shapes; the existing delete_last_voucher and document-immutability suites still pass. Refs #2364 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * docs(decisions): record the #2364 prod repair as planned, not done CodeRabbit on PR #2406: the entry read as if the seven-link repair had already run. It runs after merge on the founder's go and gets its own dated entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE --------- 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
3c033e466f |
fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole (#2348)
* fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole A residual booking anchors a bank row twice: the pointer column holds the main verifikat and one transaction_voucher_links row of role 'other' holds the small residual verifikat. reverseEntry reset the pointer unconditionally and left the 'other' row behind, so the row split across surfaces: the worklist showed it as att bokfora (is_business IS NULL) while every reader that counts junction rows (the unmatched list behind BookDirectlyDialog, the bulk_book_transactions RPC, is_transaction_booked(), the reconciliation bridge) went on calling it booked. Bulk-book refused it with BULK_BOOK_TX_ALREADY_BOOKED on a row displayed as unbooked. reverseEntry now reads the rows whose pointer it is about to reset and drops their junction rows to any other verifikat right after the reset, before the existing cleanup of the reversed entry's own junction rows. No anchor survives, so every reader agrees without a role fork or a migration; the residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. This mirrors what koppla-bort and the 1:N partial-split path already do. Tests: engine.test.ts gains the residual case and the no-pointer case and pins the pointer read before the reset; the opening-balance mock learns the read. The bank_line-only re-booking guards from #2029 stay as defense for rows left behind before this change (prod holds zero such rows). Fixes #2061 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(bookkeeping): release reversed-entry transactions and drop their supplementary links in one RPC statement Review finding on #2348 (CodeRabbit, Swedish review note): the pointer read, the pointer reset and the supplementary-link delete were three PostgREST statements. A failed read left the links behind with the pointer already reset, the exact half-anchored row #2061 describes, and a link created between the reset and the delete would have been removed from a stale id set. release_reversed_entry_transactions(p_company_id, p_entry_id) does both in a single data-modifying CTE under the UPDATE's row locks and one snapshot: the DELETE only sees links that existed when the statement started and only for the rows the UPDATE actually released. SECURITY INVOKER, so RLS and the writer-role trigger apply exactly as they did to the direct statements. Links to the reversed entry itself are still left to the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). Migration 20260906172540 applied to staging and covered by tests/pg/release-reversed-entry-transactions.pg.test.ts (main storno releases whole, residual storno touches nothing, bank_line-to-self left for the junction cleanup, tenant scope, viewer refused). Engine unit tests pin the RPC call and the best-effort fallthrough on RPC error. 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> |
||
|
|
b2d3a3e273 |
feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) (#2337)
* feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) Inställningar > Bokföring > Räkenskapsår offered Lås, Nollställ and Skapa nytt räkenskapsår but no way to change the name or dates of a year that already exists, although PATCH /api/bookkeeping/fiscal-periods/[id] has supported both (name always on an open year, dates only while the year has no posted vouchers). The only surface over that route was the first-year date editor under Företag, so a later or backfilled year saved with the wrong name or dates (Aisen & Adison AB, #2286) could only be repaired in the database. Every open year row now gets a quiet Ändra action opening one dialog (Namn, Startdatum, Slutdatum) that posts only the changed fields to the existing route. Dates are read-only with the route's own reason when the year has posted vouchers (count from the entry-count endpoint); the name is always editable. Locked and closed years get no Ändra, matching the route's refusal and the row's chip. Route refusals are shown inline so the user can correct and retry. The name follows the dates while it still has the shape fiscalYearName() produces (new isDerivedFiscalYearName, the inverse predicate next to the one naming helper): the customer's "Räkenskapsår 2027" corrects itself to "Räkenskapsår 2022/2023" as the dates are fixed, and a hand-written name is never overwritten. Saving invalidates ref:fiscal-periods so every picker updates; the reset dialog's typed confirmation reads the name live from the reset snapshot, so a rename does not break it. No route change. New strings in both messages/sv.json and messages/en.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(settings): fiscal-year edit dialog is not dirty on open when the stored name has stray whitespace "Changed" now compares the raw input with the stored name and sends the trimmed value only once the user has edited it; before, a stored name with leading or trailing whitespace enabled Spara on open with a trimmed name in the payload (CodeRabbit on #2337). 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> |
||
|
|
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>
|
||
|
|
9618bab273 |
fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.
1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
seeds the next forward year and kept that name when the user re-dated the
form. The name now follows the typed dates until the user edits the name
(fiscalYearName exported from suggest-fiscal-period).
2. Nollställ of the backfilled year was refused with next_year_dependency
because 2024 carried an opening-balance verifikat. Any IB in the next year
counted as reliance, so a backfilled year could never be reset, while a
next year WITHOUT an IB (whose balansrapport really rolls from this year)
was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
the block fires only when the next year is locked, closed or has its own
closing entry; a bokslut-generated IB is still refused via this year's
closing_entry_id (year_end_state). The snapshot returns next_period
{id, name, has_opening_balances} and the dialog states that the following
year's IB stays as it is.
pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
cc18e9d530 |
fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237) (#2242)
* fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237) POST /api/bookkeeping/fiscal-periods decided "first period" as "no period exists at all", so a company that imported 2024+ from Fortnox and then created its actual first year by hand (2022-07-22, the registration date) was refused with the 1st-of-month error, while the DB trigger enforce_first_of_month_for_subsequent_periods would have accepted the row. The route now mirrors the trigger: first = no existing period starts earlier. The 1st-of-month rule (BFL 3 kap. 1 §) keeps binding subsequent years, and its message now says which years it binds and why instead of only refusing. Tests: prepend with a mid-month start passes; a mid-month start for a non-earliest period is still a 400 that names the rule. Closes #2237 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * chore: carry the DECISIONS.md line for this PR in #2247 instead (append-only log conflicts on every merge) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1c82baf553 |
feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)
* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the deadlines page selected invoices by status alone. A sent proforma past its due date was chased with a betalningspaminnelse and flipped to 'overdue', and it appeared as a receivable. All four now filter document_type = 'invoice', which is also the precondition for adding quotes (offert): a quote carries a date but never a receivable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion Adds document_type 'quote' with valid_until, quote_status (open / accepted / declined; expired is derived from valid_until, never stored) and quote_decided_at. Quotes are numbered OF-nnn at insert from company_settings.next_quote_number via generate_quote_number(), the same pattern as delivery notes, so a declined quote never leaves a hole in the F-series the way a proforma does. The column next_quote_number already existed on prod and staging without a migration; the migration adopts it. Engine: build-invoice-write writes the quote columns and keeps remaining_amount at 0; the draft editor refuses accepted or declined quotes; PATCH refuses changing a quote's or delivery note's document type since the number belongs to the series; mark-paid refuses quotes. New POST /api/invoices/[id]/quote-status records the decision and locks once an invoice exists. Conversion is extracted into lib/invoices/convert-to-invoice.ts (one implementation for the route and the MCP staged commit, which had drifted): a converted quote stays and flips to accepted, the invoice links back via converted_from_id and gets its due date from the customer's payment terms; a declined or already invoiced quote is refused. next-number previews the OF-series for quotes. Migration applied to the staging branch and registered as 20260902140000; the pg test runs in CI (pg-real). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): quote PDF, email and filename surfaces The customer-facing surfaces get a quote sibling for every proforma branch: PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the due date, a notice that the document is not an invoice or a payment request, and no payment box, OCR, bankgiro, Swish, QR or payment link. The email says the quote is attached and valid until the expiry, drops the payment details and pay-online button, and asks about the quote rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT number and payment accounts are skipped for quotes as for proformas: a quote is not a faktura under ML 17 kap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert in the editor, list and detail pages Editor: "Offert" document type with a required "Giltig till" field (default today + 30 days) in place of the due date; the wire body mirrors it into due_date so the shared schema is satisfied. Payment link, ROT/RUT, periodisering and the bank box are already gated on real invoices. The type cannot be switched on an existing quote (its OF-number belongs to the series). List: an Offerter tab beside Proforma, "Ny offert" in the split button, and a status column that shows the decision or the derived expiry: Utgången and Avböjd are exception chips, Öppen and Accepterad muted text. Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow menu; an expired quote asks before accepting or invoicing (bypassable); once an invoice exists the page links to it as Fakturerad and hides the decision actions. Strings in both sv and en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface MCP: create_invoice takes document_type quote with a required valid_until and allocates the OF-number at insert; the convert tool keeps its id and accepts quotes with the registry refusal codes; new set_quote_status; list_invoices and get_invoice expose valid_until and the effective quote status, including a derived expired filter. The tools/list payload stays under its ceiling without a ledger change. The MCP staged convert now uses the shared converter. v1: POST /invoices/{id}/quote-status (registered in the endpoint registry, scope map and route loader), valid_until and quote_status in the list, create and detail shapes, and a quote_status list filter. Skill atoms mention offert. Decision log lines for the own number series, derived expiry, accepted-not-cancelled conversion and the header action layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * test(invoices): pass route params and period id in the new quote tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * fix(invoices): close the quote review findings in one pass Skeptics (correctness, compliance, regression) and CodeRabbit on #2163: - quote_status is no longer a write-builder output, so a v1 PATCH or MCP update_invoice can never reset a recorded accept/decline; new quotes are opened by the invoices_quote_defaults trigger (20260902141000), which also keeps due_date and valid_until equal. v1 PATCH and the MCP update executor now use the shared editable-draft predicate. - One live invoice per converted source, enforced by a partial unique index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and both quote-status routes compare-and-set on the decision they read. - MCP-created quotes carry remaining_amount 0; mark-paid, transaction match and voucher link refuse non-invoices on the MCP staging tools, the executors and the dashboard link route. - Conversion of a foreign-currency source refetches the rate for the conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one; 0-day payment terms mean due on receipt. - bulk-create refuses quotes per item; list_invoices rejects a quote_status filter combined with another document_type; an omitted document_type on PATCH means unchanged. - attention, push notifications, open-AR count, FX revaluation, year-end and accrual auto-detect and bank-match suggestions only read fakturor. - Quote PDF and email print Summa / Total instead of Att betala. - Regenerated skills/accounted-api for the new v1 endpoint. Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY on the migrations (repo precedent, 13.8k rows, transactional apply); re-validating VAT treatment at conversion (the converted invoice is a draft the user reviews; follow-up). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): second review round: migration versions, order links, batch allocation, races - Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its own 20260902141000 to prod while this PR was in review and prod's head moved past both files; below-head versions are skipped by branching, which would have left the quote trigger off prod. Staging rows renamed. - Quote lines never carry sales_order_item_id (an offer must not count as invoiced kundorder quantity); the converter carries a proforma line's order link onto the invoice. - Converter compare-and-sets the source (proforma cancel, quote accept): a concurrent cancel, proforma-to-order conversion or decision removes the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a second document for the same sale. - MCP set_quote_status gets the same compare-and-set as the HTTP routes; 0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere. quote-status (dashboard, v1, MCP) accepts valid_until so an expired sent quote can be reopened, as the docs promised. - MCP mark-paid refuses only quotes, parity with the dashboard route (a sent proforma marked paid is a supported prepayment record). - Batch allocation (dashboard route and MCP tool) refuses non-invoices before the RPC, which gates on status alone. - Customer AR drill-down, v1 customer open invoices and archive guard, and the calendar feed read fakturor only. - Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the editor locks the document type on existing quotes and delivery notes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms - 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a live converted invoice exists (the compare-and-set in the three decision writers could still be beaten by a conversion landing in between); the routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED. generate_quote_number now also requires a non-viewer membership so a viewer's session token cannot burn OF-numbers through PostgREST. - Converter checks quote eligibility before the Riksbanken call and treats a failed company_settings read as a failure instead of a 30-day default. - Re-sending the same decision keeps quote_decided_at (idempotent). - gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its write sibling; the dashboard link route surfaces a failed lookup. - Late-fee and credit-term texts never print on a quote. Applied and registered on staging; pg tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK - match-batch surfaces a failed document lookup instead of allocating. - v1 quote-status dry-run preview carries the new valid_until. - Quote PDF heading reads Offertinformation / Quote information. - 20260902222000 also pins the date invariants the trigger maintains as a CHECK: a quote always has valid_until = due_date, nothing else has one. Applied on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f1230282a9 |
feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings A company running several bank accounts (main bank on A, company card on M, both imported via CSV) could not route each account's bookings into its own series: every bank_transaction booking took the single company-wide default from default_voucher_series_per_source_type. - cash_accounts.voucher_series (nullable, single letter): per-account override, editable under Inställningar → Bokföring → Verifikationsserier per bankkonto (new PATCH /api/cash-accounts/[id]). - resolveCashAccountVoucherSeries(): step 2 of the resolution order (explicit pick → account override → per-type map → A). Wired into the book route and createTransactionJournalEntry, which covers categorize, the agent, pending operations and the v1 API. - Booking dialog gets the series picker, seeded from the server via /voucher-sequences/next?source_type&cash_account_id so dialog and route can never disagree. An unresolved embedded picker omits voucher_series so a stray 'A' never overrides the account's series. Scope: bank_transaction bookings only. Invoice settlements matched from the bank keep their payment series; bulk-book resolves inside its RPC (see DECISIONS.md). Migration applied to staging as 20260902121420. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh * fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish compliance review): - Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series is a behandlingsregel that outranks the audited per-type map. New trigger audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in the report, pg-real test. Applied to staging as 20260902124513. - Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a verifikationsserier_regler block with the resolution order and the two exceptions (invoice settlements, samlingsverifikat); the per-account mapping itself is in data/cash_accounts.json. - Settings picker uses the same closed list as the manual verifikat form (presets plus letters already in use) instead of all 26 letters; strings moved to messages/sv.json and messages/en.json. - /voucher-sequences/next applies the account override only for source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot show a series the entry will not get. - Book route resolves the series from the account the row ends up on after a stranded-row repoint, not the stale one. - PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a Postgres cast 500; the series lookup logs a warning when it fails open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f266c386f3 |
chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e5fba9471e |
feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal. 1. Verifikationsserie is a closed dropdown, not a one-letter free-text field. Ships Fortnox's table verbatim from their Systemdokumentation (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not Kundfakturor: it is the general series manual entries land in, and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Any letter the company already configured is appended so no existing value can fall out of the picker. 2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl. 3. Tabbing or clicking into an untouched amount proposes the outstanding difference, pre-selected so typing replaces it. Only fires when the row has an account, both amounts are empty, and the difference belongs on that side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN |
||
|
|
f216a60bf8 |
feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e313bfa8ec |
fix(invoices): ROT/RUT kontantmetoden invoices could not be marked paid (#2040)
* fix(invoices): derive mark-paid amount as customer settlement, not gross debit sum remaining_amount on a ROT/RUT invoice is stored net of the deduction (total - deduction_total): the customer owes only their share, and Skatteverket's share sits on 1513 until the payout flow clears it. The kontantmetoden payment entry correctly books two debit legs (bank = customer share, 1513 = deduction), but both mark-paid routes summed ALL debit lines as the payment amount, so the gross total was compared against the net remaining and every ROT/RUT cash invoice was rejected with MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total, stalling the whole ROT chain (unpaid invoice never becomes a payout candidate). New deriveCustomerSettlementAmount in lib/invoices/apply-invoice-payment excludes the net 1513 debit, capped at the invoice's own deduction (so invoices without a deduction keep byte-identical behavior, including rejecting a hand-added 1513 overshoot), and both the dashboard and v1 mark-paid routes use it. The verifikat still books the full entry including the 1513 leg; only the settlement math changes. Reported by a user unable to mark ROT invoice 1123 as paid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ * fix(invoices): harden ROT settlement derivation per skeptic refutations Three fixes from adversarial review of the previous commit: 1. v1 mark-paid never fetched deduction_total (the pre-flight select projects explicit columns), so the exclusion cap was always 0 and the v1 API still failed with MATCH_AMOUNT_EXCEEDS_REMAINING. Fetch it ad hoc next to journal_entry_id (kept out of the response contract) and assert the projection in the route test, since the mock harness ignores select strings. 2. Gate the 1513 exclusion on the invoice NOT being booked yet, in both routes. An invoice booked at send already debited 1513 in its registration entry; ungated, a cash-shaped payment entry on such an invoice would post (orphaned 1510, doubled 1513, double revenue and VAT) where the gross guard used to reject it. 3. payment-sync's reversal recompute now stores remaining_amount net of deduction_total, matching build-invoice-write and the DB guard. Recomputing gross made a storno'd ROT cash invoice permanently un-payable under the net settlement derivation (net payment can never reach a gross remaining; the cash-partial block rejects the rest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d11d0a2e90 |
feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553 |
||
|
|
4e1eb3d662 |
fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643) A broken bank reconnect leaves cash_accounts rows that share the live account's IBAN (held by a revoked connection, or demoted to manual by the #916 fix). Three consequences are fixed here: - Problem 4 (silent mis-booking): the own-account transfer detector paired with such an orphan and proposed its ledger as the counter-account, and a counterparty template learned from that result replayed as 1940/1931 in the booking dialog. The detector now tolerates several rows on one IBAN, never pairs with the transaction's own row, a disabled row, or a revoked holder; the mapping engine drops a "transfer" whose counter equals the settlement account; suggest-categories withholds learned suggestions that reference an orphaned ledger; and both commit paths (POST /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are only refused in the COUNTER position: a stranded row still settles on its own ledger, and a manual account without a live IBAN twin is never treated as orphaned, so transfers between two live accounts keep booking. - Problem 1 (match dialog): the ranked unmatched-entries path also offers vouchers booked on sibling ledgers of the same IBAN, and manualLink accepts a voucher line on a sibling ledger. When it does, the same locked UPDATE re-points transactions.cash_account_id to the live sibling row (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the account-keyed reconciliation does not count a cross-account link as an imbalance on both ledgers. - Problem 3 (naming): allocatePsd2LedgerAccount names the chart account BAS-style (BAS reference name for a standard slot, else "Bankkonto <CUR>") instead of the ASPSP-reported holder name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643) One in-memory topology (cash_accounts rows + bank_connections status) now defines "live", "orphaned" and "same physical account" for the transfer detector, the match/link flows and every commit guard, so a proposal is never made that a guard later rejects. - Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban treats the transaction's own IBAN as "not a transfer": every same-currency row on that IBAN is the same physical account, whichever is live, so interest stamped with the own IBAN never pairs with a twin (two active rows, a demoted-manual twin, or a live twin of a stranded row). Only a pocket in another currency on that IBAN can still pair. guardCounterLegs refuses a same-IBAN same-currency twin in the counter position on every commit path, even when both rows are active. - Finding 3: with several surviving candidates (currency pockets with no discriminator, or two active twins) the finder returns null instead of picking the lowest ledger, which is what the pre-PR lookup did. - Finding 5: the finder drops every row in the orphaned set, the same predicate the commit guards use (demoted-manual twins included). - Finding 9: "live" means enabled + connection status 'active'; an expired/error twin of a live row is orphaned, a lone expired connection (re-auth window) is not. - Finding 2: siblings are keyed on (normalized IBAN, currency) in describeCashAccountSiblings and the unmatched-entries route, so a SEK transaction can no longer link to a voucher whose only bank leg is on the EUR pocket of the same IBAN; manualLink rejects that as before. - Finding 8: manualLink re-points a row only when the voucher sits on the LIVE sibling and the own row is not live; the reverse direction links without moving the row. - Finding 7: the v1 REST categorize route runs the same guardCounterLegs check after account_override and returns TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through categorizeMatchedTransaction, already covered. - Finding 10: a learned template whose stale 19xx leg is a twin of the settlement row is rewritten to the settlement account (it is the bank leg, not the counter) instead of refused; suggest-categories exempts each transaction's own settlement ledger before withholding a suggestion. The error message now covers both the twin and the disconnected case. Tests pin each behavior (service, detector, manualLink, unmatched-entries, dashboard and v1 categorize routes, suggest-categories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-2 review findings (#1643) 1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology now keys the live twin on normalized IBAN plus currency (the rule every other "same physical account" check in the PR already used), so a manual or deselected GBP/EUR pocket beside a live SEK pocket of a multi-currency account is never orphaned, still pairs in the transfer detector and is accepted as counter at commit. Twin computation is shared (twinLedgersOf). 2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is a twin of the transaction's own row is rewritten to the settlement ledger in the offered suggestion instead of being withheld; only a true counter-position orphan (or a twin that would book the settlement ledger against itself) is withheld. One topology load per batch (loadCounterLegTopology). 4. The free-form dialog path (POST /api/transactions/[id]/book) gets a line-level guard (guardBookedCounterLines): a 19xx line that is a twin of the transaction's own row or an orphaned ledger, alongside the settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. Only runs when the lines touch two distinct 19xx ledgers. The twin rewrite in suggest-categories (2) covers the both-active shape before the dialog is even opened. 5. manualLink re-points the row onto the sibling ledger the voucher was booked on whenever the sibling is live or the own row is not (both-live twins and both-dead rows included); only a live row whose voucher sits on a dead sibling links without moving. unmatched-entries now uses describeCashAccountSiblings and does not offer dead-sibling vouchers to a live row. DECISIONS.md: the PR's existing review follow-up line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-3 review findings (#1643) 1. Revoked-held rows are no longer orphaned unconditionally. A row whose connection is revoked is orphaned only under the twin rule (not live AND a live row shares its normalized IBAN + currency), so a disconnected-but-real account (the company's only 1930, or two real accounts on one revoked connection) stays pairable by the transfer detector and bookable as counter on every guarded path. Tests cover the no-twin case for getOrphanedCounterLedgers, findPairableCashAccountByIban, detectOwnAccountTransfer, guardCounterLegs and guardBookedCounterLines; the existing revoked tests now use a twin shape. 2. manualLink / unmatched-entries decide the re-point on the destination: a new shouldRepointToSibling moves onto a live sibling, or onto a dead one only when the own row's holder is gone (released: bank_connection_id null or revoked) and no sibling is live. An expired/error/pending own row links without moving. SiblingCashAccount gains `released`. Tests: expired own row + demoted twin links without moving and the twin's vouchers are not offered. 3. loadCounterLegTopology is exercised directly: settlement ledger and twins, other-currency pocket, null/unknown ids, cache, orphan set equal to guardCounterLegs' refusals on the same fixture, lookup failure. 4. guardBookedCounterLines docstring and the /book route comment now state that only the two-cash-legs shape is inspected; a single hand-typed 19xx line is not (covering it would cost a cash_accounts lookup on every ordinary booking). DECISIONS.md lines amended accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-4 review findings (#1643) 1. Same-connection re-registration twins (the dominant prod shape): two enabled rows on one active connection sharing (IBAN, currency) are now told apart by balance_updated_at; only the most recently synced row is live, the other is a stale twin (orphaned as a counter, never a re-point destination, and the transfer detector pairs with the syncing row alone). Rows with no stamp or the same stamp both stay live. 2. POST /book: a single 19xx line that is a sibling ledger the row should move to (the live twin of a stranded row) re-points cash_account_id in the same locked UPDATE that links the voucher, mirroring manualLink. guardBookedCounterLines returns { refusedLedger, repointCashAccountId }; an ordinary booking pays one PK read of the own row. 3. manualLink refuses the link (success:false, Swedish error) when the voucher sits only on a dead sibling instead of writing a cross-account link with a server-side warn; the REST and MCP link callers reach it without the unmatched-entries filter. 4. manualLink judges a voucher touching several sibling ledgers on the best of them (a live sibling, else the first the row may move to) instead of the first line PostgREST returns. Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route; the two DECISIONS.md lines for #1643 amended in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-5 review findings (#1643) 1/2/5. Same-connection twin liveness no longer ranks on cash_accounts.balance_updated_at (a connect-time snapshot the sync never refreshes, inverted on prod in 4 of 5 stamped groups). The live row is the one whose external_uid the bank still lists in bank_connections.accounts_data (rewritten on every sync); no listing, both listed or neither listed keeps both rows live (round-3 behavior). getConnectionStatuses selects accounts_data in the same query. 3. guardBookedCounterLines single-19xx-line shape: a twin the row may not move to (dead or disabled) is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only bank leg on the dead ledger; an unrelated 19xx line still posts as typed. Route test added. 4. Disabled cash_accounts rows are never siblings, so neither manualLink nor /book re-points a transaction onto a deselected row; a voucher booked only there is refused as a cross-account link. 6. PR body rewritten to the final rules; DECISIONS.md round-4 line amended (signal correction, /book refusal, disabled siblings). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643) CashAccount.external_uid is nullable in the shared type; the same-connection twin rule now skips null uids instead of passing them to Set.has, which failed the strict type check in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643) Two enabled rows on one active bank connection sharing (IBAN, currency) are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at and round 5 on external_uid presence in bank_connections.accounts_data; each was verified against prod and each was contradicted by it (ingest routes by the accounts_data entry's ledger_account, which in two groups points at the OLD row, so the "stale" row is the one still being fed). Restores the round-3 behavior: neither twin is orphaned, the transfer finder returns null when both survive, no guard refuses either, and shouldRepointToSibling treats both as live siblings. No replacement signal; how to model the shape is a founder decision (PR #2010 review). getConnectionStatuses no longer selects accounts_data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cb9ae15d46 |
fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora reverseEntry() unlinked bank transactions from the reversed entry by clearing only journal_entry_id. The worklist's "unbooked" predicate is is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so the row stayed "handled": absent from Att bokfora and from the nav badge, while the storno dialog (reverse_warning) promised the opposite (#1950). The engine now resets the same triple the uncategorize paths write (journal_entry_id, is_business, category) plus reconciliation_method, scoped to rows linked to the reversed entry. Fixed in the engine so the dashboard, v1 and MCP reverse doors all agree. Closes #1950 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows through transaction_voucher_links only (journal_entry_id stays NULL), so the reset matched nothing there: all rows kept is_business = true against a status='reversed' entry, stayed out of Att bokfora and the nav badge, and is_transaction_booked() still reported them booked. The N=1 variant left a dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and kept the reconciliation bridge bucketing the row as matched. reverseEntry now deletes the reversed entry's junction rows (the same removal koppla-bort performs) and releases is_business, category and reconciliation_method only for rows left with no anchor: a remaining-links read plus journal_entry_id IS NULL guards residual bookings (main verifikat in journal_entry_id, junction row to the residual verifikat) and multi-allocated rows so stornoing one voucher never unbooks a still-booked row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink The widened reverseEntry reset (#1950) now nulls is_business, category and reconciliation_method together with journal_entry_id on the linked transaction, but the fix-cash-mismatch remediation relinked with only journal_entry_id. The repaired row ended up booked (pointer at the posted clearing entry) yet visible in Att bokfora and the nav badge (worklist predicate: is_business IS NULL), the inverted #1950 symptom; booking it from the list would conflict-storno the correct clearing entry and corrupt the AR chain the route just repaired. The relink now restores the full booked triple, mirroring the match-invoice route's final update. New route tests cover auth 401, validation 400, the no-targets path, and assert both relink payloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
175bb8bd92 |
fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business side regardless of sign, so a learned line_pattern whose ratios over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02) produced an entry off by 2x|diff|. commit_journal_entry rejected it, so the user saw a failed confirm and, since #1894, an unbalanced prefill. The 3740 leg now lands on the business side for a positive diff (under-allocation, unchanged) and on the opposite side for a negative diff (over-allocation), flipped after the mirror. computeProposalLines gets the identical rule in the same change to keep the byte-parity contract, and a 5000-amount sweep test pins engine and proposal together. Also reachable with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02. Closes #1898 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ff88e3de05 |
fix(bookkeeping): remove false 1580 tax-receivable label, let company account names win (#1968)
The hardcoded ACCOUNT_DESCRIPTIONS entry labeled 1580 'Fordran for skatt' with a Skatteverket explanation. That is wrong on both counts: tax receivables are 1640 (skattefordringar) / 1650 (momsfordran), and 1580 was traditionally 'Fordringar for kontokort och kuponger', which BAS has since moved to 1686 (why 1580 is excluded from our BAS 2026 catalog). Reported by a user who books card/Swish settlements there. Also flip AccountNumber display precedence to the company's own account_name over the hardcoded reference name: chart rows are user-editable data and must not be visually overridden by our copy. The tooltip keeps showing the BAS reference info. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a41119682 |
perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline
Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.
Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
(the API key panel imported STAGING_SCOPES from the key generator).
BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
dynamic import, fetched once per session after first paint; components
that show BAS names/descriptions call useBasReference() and re-render
when it lands. Until then (and on the server) only the hardcoded
account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
(account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
scripts/generate-bas-account-numbers.ts (--check) + parity test:
isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
heuristic shared by the server classifier and a client variant that uses
the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
invoice-entries.ts, whose engine import pulled account-backfill and the
chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
OpeningBalanceRowEditor builds its Fuse indexes lazily; the
ChartOfAccountsManager BAS-katalog tab awaits the chunk.
Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
'use client' module with the shortest chain to a target (file or bare
specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
module reaching a Node builtin is a hard failure (0 today).
Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)
One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
0bb482bf6e |
feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader Proposal views (AI suggestion, static template, counterparty template with or without a line pattern) previously offered only accept-or-start-over: the verifikation preview was pure rendering and the only line-editable path was library templates. This adds an "Andra rader" affordance to the proposal view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK amounts, VAT legs, exactly what the preview shows) into TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing the same initialLines mechanism library templates already use. - lib/bookkeeping/proposal-lines.ts: line computation extracted from JournalEntryPreview into computeProposalLines() (single source for preview and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping to the JournalEntryForm prefill shape. The settlement leg is flagged so the booking dialog swaps in the transaction's resolved cash account and stamps currency metadata, mirroring buildInitialLinesFromTemplate. - JournalEntryPreview now renders computeProposalLines() output unchanged. - TransactionBookingDialog accepts proposalLines (takes precedence over preselectedTemplate); the booking still goes through JournalEntryForm's normal manual validation and the engine, no validation bypassed. - Ore rounding funnels through roundOre(); guard baseline ratcheted down. - New strings in messages/sv.json and messages/en.json (tx_quick_review). - Unit tests for all three proposal branches incl. VAT legs, reverse charge, multi-line patterns, 3740 rounding diff and FX metadata. Fixes #1878 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings) Three skeptics refuted the first cut of #1878: the extracted preview math was a lossy approximation of the engine, and making it bookable made every loss a real booking defect. This commit closes each refuted scenario by mirroring the exact engine path per proposal branch: - Balance: VAT is single-rounded and the net leg is gross minus that VAT (transaction-entries.ts semantics). Independently rounded net+VAT went off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94), prefillling an unbookable verifikat. - 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via resolveExplicitVat before computing lines, so an explicit no-VAT choice prefills no VAT line instead of re-deriving the 25% category default into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments). - Ore parity: engineRound (plain Math.round(x*100)/100, matching the engine) replaces roundOre where the engine is naive; roundOre kept only where the engine uses it (category VAT leg). No more 1-ore drift between preview, prefill and the booked verifikat (8.62 RC, 34.30@12%). - Legacy counterparty pairs: new counterpartyLegacy mode mirrors the legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair (previously dropped: an RC expense would have booked without fiktiv moms, understating rutor 30/48), VAT on expenses only, income gross, and sign-mismatched matches mirrored like buildLegacyMismatchResult. - Pattern mirror: sign-mismatched line patterns flip learned sides like buildMultiLineMappingResult; ratio allocation filters business/tax types. - Entity accounts: static template accounts resolve debit/credit_account_ab for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer previews or books EF-only accounts like 2013. - Settlement swap: only a literal-1930 settlement leg is swapped to the resolved cash account (applySettlementAccount parity); learned non-1930 money legs (1510/2440/2890/19xx) stay authoritative. - FX: QuickReviewDialog hands its enriched transaction row to the booking dialog so the settlement leg's exchange_rate metadata matches the rate the SEK amounts were computed with. 34 unit tests incl. every skeptic counterexample; guard baseline ratcheted to 622 (below main's 626). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation) Two independent skeptics refuted the pattern branch: the engine books the money leg on the counterparty template's learned legacy account (credit for an expense, debit for an income, mirror-swapped, falling back to 1930), while the preview/prefill defaulted to 1930. A SIE-learned pattern settling on 2440 showed kredit 1930 in the preview but booked kredit 2440 on confirm. QuickReviewDialog now passes the learned pair raw (no entity resolution, engine parity) and computeProposalLines selects the settlement account exactly like buildTransactionEntryLines; the literal-1930 swap to the resolved cash account is unchanged. CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740 rounding line keeps the engine's business-side placement for both diff signs (parity contract; an unbalanced set is rejected at commit), and the naiveOreRound baseline stays at 622 (engineRound is a documented parity exception). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd46d936f3 |
fix(bokforing): account search results in the booking dialog search field (#1877) (#1889)
* fix(bokforing): account search results in the booking dialog search field (#1877) The search field in the "Bokfor transaktion" dialog only matched template metadata, so typing an account number or name (e.g. active 5460 Forbrukningsmaterial) gave zero hits and no path to booking; the real account search was only reachable via the discreet "Bokfor manuellt" link. - TemplatePicker now also searches the company's active chart of accounts (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton" result group; picking one routes into the same manual booking flow as "Bokfor manuellt" with the account prefilled on the counter line. - New buildActiveAccountIndex helper indexes chart rows active-only, so deactivated accounts never surface as bookable results. - searchTemplates additionally prefix-matches all-digit tokens against a template's business account (debit for expense, credit for income, both legs for transfers, AB variants included); the settlement leg is deliberately excluded so "1930" does not light up every template. - Library template search prefix-matches line accounts on all-digit queries. - Empty search results show a hint pointing at "Bokfor manuellt" for accounts outside the active chart; placeholder now says "Sok mall eller konto...". New strings in both messages/sv.json and messages/en.json. Fixes #1877 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokforing): match only business lines when digit-searching library templates (#1877) CodeRabbit review on PR #1889: the all-digit library-template match also hit settlement and VAT lines, so searching "1930" lit up every user template with a bank settlement leg. Restrict the predicate to business lines, mirroring the static catalog's settlement-leg exclusion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c62321988b |
feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0ed26c2eca |
fix(mcp,bookkeeping): FX-safe mapping-rule VAT + honest nullable output schemas (#1846)
Follow-up sweep to #1842 for sibling bugs of both classes. FX denomination (same defect as categorize's vat_amount): - mapping-engine buildResult computed input VAT, reverse-charge fiktiv moms AND the rutor 20-24 basbelopp pair from the transaction-currency amount while journal lines are SEK. Now resolved through the same lenient SEK ladder buildTransactionEntryLines uses for the gross. Reachable at the ledger only via env-gated auto-book (dev/test) and the mapping-rules evaluate API, but one flag from production. - categorize-core's header doc claimed transaction.amount already IS SEK; rewritten, it was license for exactly this bug class. Output-schema nullability (same defect as matched_supplier_id): - get_payslip.calculation_breakdown: returned as explicit null before calculation (the schema description even said so) but typed object. - get_document_content.mime_type/size_bytes: verbatim nullable columns. - export_sie: company_name nullable, and org_number was returned but never declared under additionalProperties:false, so strict clients failed EVERY successful export. - currency (two transaction listings) and voucher_series (verifikat listing): nullable columns with defaults, loosened defensively. - New declaration-pinning test in output-schema.test.ts. Known and deliberately not fixed here: skatteverket bank-counterpart hint compares a foreign amount to SEK (display-only, missed hint); own-account-detector cross-currency magnitude tie-break (heuristic); invoice-inbox still withholds rule proposals on foreign rows (guard can be lifted separately now that the defect it guards is fixed). Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2860cd51b1 |
fix(mcp): vat_amount in transaction currency + nullable matched_supplier_id (#1842)
Two MCP-agent-reported bugs (feedback seq 254607, 261972; also reported by mail): - categorize_transaction validated vat_amount against the transaction- currency gross but posted the raw figure as SEK: a 15.87 USD override on a 79.34 USD Stripe payment booked 15.87 kr to 2611 instead of ~150.92 kr, and the entry still balanced so nothing could catch it. buildMappingResultFromCategory now resolves the same SEK value the line builder uses for the gross and books ALL VAT figures off it: the override (scaled by the settlement ratio), the auto-derived rate VAT, and reverse-charge fiktiv moms, which had the same defect for every foreign-currency transaction. SEK transactions are unchanged. The vat_amount schema now states the denomination; the override bound error names the currency. - complete_document_upload / upload_document declared matched_supplier_id as a bare string while unmatched uploads correctly return null, so strict clients failed every successful unmatched upload and tripped the caller's circuit breaker. The schema is now ['string', 'null'], matching the runtime. Catalog token ceiling 59.9K -> 59.95K per the documented ratchet protocol (prose trimmed to the floor first; headroom was ~19 tokens). Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7cf15a105f |
fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8e3015e541 |
fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.
Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.
Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
OPEN unlocked fiscal period with entry_date = period_start and appends
"Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
verifikationstext. Interior gaps, future dates, and a closed/locked
first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
forsta dag (<date>)" for pre-FY dates instead of proposing a
pre-registration year; "Skapa rakenskapsar" remains for the other
no_period cases.
No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.
Fixes #1825
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
62135c0c02 |
fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)
A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning dialog + MCP path) had no öre handling, so a sub-krona overshoot was rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice in partially_paid forever. Invoices already stuck that way had no exit: the mark-paid dialog proposed clearing the full total (rejected with MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid outright. Fix, both halves: 1. New migration 20260824120000 replaces match_batch_allocate with the same öresavrundning band every single-payment path already uses (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff clears the FULL remaining off 1510/2440, books the residual to 3740 with correct polarity per side, records the full remaining as paid and flips the status to paid. >= 1 kr diffs keep today's behaviour. 2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual invoice gets a proposal clearing the actual remaining, and a sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so one click closes a stuck invoice. The mark-paid route and the invoice-detail button now accept partially_paid (the settle layer's CAS guard always did). Forward-only: already-stuck invoices are not auto-repaired; they are closed via the new dialog proposal. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d4c42fd8db |
fix(bookkeeping): journal search finds a voucher by its label, with a spinner while searching (#1808)
"Sök verifikationstext" only matched journal_entries.description, so typing A209 never returned voucher A209 itself: only other vouchers whose text mentioned it. Users read that as "the voucher is missing". A label-shaped needle (A209, a 209, A-209) now also matches voucher_series + voucher_number via a PostgREST OR (needle double-quoted so commas/parentheses stay literal); other needles keep the plain description ILIKE. parseVoucher accepts one space or hyphen between series and number. The search box shows a spinner while a server-side search is in flight; before, the only signal was the list dimming. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43cde6deb9 |
fix: unignore transactions during categorization (#1683)
Fixes #1660 |
||
|
|
e030393fe6 |
fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md). Payment side (fakturamodellen: the customer pays total minus avdraget, the rest is a 1513 receivable on Skatteverket): - createInvoicePaymentJournalEntry without an explicit paymentAmount used to book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the outstanding amount, so on a ROT/RUT invoice 1510 went negative by the deduction and 1930 was overstated; same defect for any previously part-paid invoice. It now books the outstanding amount (remaining_amount, else total minus paid_amount); a fully outstanding invoice keeps the total_sek path. - proposePaymentLines had no deduction awareness: the payment dialog pre-filled D1930 total / K1510 total, which the settlement plan rejected as an overpayment, so a ROT/RUT invoice could not be marked paid from the UI. Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices without a booking rate refuse (1513 is a kronor receivable). Dialog passes deduction_total. - Reminders and dröjsmålsränta were computed on invoice.total: a privatperson was dunned for the Skatteverket share and charged interest on it. New reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus avdrag) drives the processor's interest base and all three templates. Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar): - work_type and labor_hours were optional at creation but hard blockers at begäran-file time, when the invoice is numbered, booked and paid and cannot be edited. validateDeductionLines() now requires a same-kind arbetstyp and hours > 0 (schablontjänster exempt) on every deduction line; wired into validateInvoice, CreateInvoiceItemSchema (field-level issues) and the editor schema with inline errors under the ROT/RUT strip. Fixed the labor_hours register (valueAsNumber overrode setValueAs: an emptied field became NaN and failed validation with no visible error). The Underlag card now shows whenever any row is flagged, matching the payload/server predicate. Yearly ceilings: - COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped at 50 000 inside it). deductionCapWarnings() carries the per-kind and the combined check plus optional prior-year totals; validateInvoice forwards them; the editor uses the same helper and fetches what the customer has already been granted in the invoice year (per customer, warning only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has ~330 such open invoices. Booking 0 would have failed the engine's positive- amount rule, so the outstanding helper derives total - paid - deduction when the stored value is not positive. Test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): review follow-ups on #1652 - ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema / UpdateInvoiceSchema share one refine) so it only applies to real invoices and skips text rows; the editor gates its mirror on the document type via a ref. Tests moved accordingly (CodeRabbit). - Prior-year deduction lookup follows the PAYMENT year (paid_at, else invoice_date for open invoices), paginates via fetchAllRows, and clears the total on a failed request instead of leaving a stale one. - rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the validator and the generator cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling) The runtime-built .or() filter counted as an unresolvable query expression for the no-phantom-columns guard. A customer has few deduction invoices, so fetch them all and select the payment year in code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fbe4e18730 |
feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link gnubok_categorize_transaction only spoke a 19-category enum mapping to 21 hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were unreachable from the agent surface even when active in the chart. - add account_override to gnubok_categorize_transaction with v1 REST semantics via a shared helper (lib/bookkeeping/account-override.ts): business-side replacement, class-2 auto-VAT drop with the 2610-2649 moms-line exception, plus a same-account degenerate guard; validated at staging and re-validated at commit - align the gnubok_create_voucher staging gate with the engine's seeding semantics: BAS 2026 accounts merely absent from the chart pass (the engine backfills them at commit) and the preview lists will_activate_accounts with BAS-name fallback; non-BAS unknown and inactive accounts still rejected - stop suggest_categories silently dropping mapping rules whose account is outside the fixed category maps; they surface with the rule's own account and an explanatory match_reason - correct the create_account next-step hint (categorize could never use the new account before; now true via account_override) - point the settings "Kontoplan (BAS)" link at /chart-of-accounts and redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in #850; the deep link never worked after the #854 merge collision) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): address review findings on account_override - commit executor rejects a present-but-malformed stored account_override loudly instead of degrading to the category default (CodeRabbit major; the approver approved a preview showing the override account); with commitPendingOperation regression tests - accountToCategory returns null for unknown income accounts so custom income accounts get the same diagnostic as expenses (CodeRabbit minor), with income + reason-accumulation tests (CodeRabbit nit) - pin the class-2 VAT-drop balance invariant with a test through buildTransactionEntryLines (Swedish compliance review: gross booking, never an unbalanced net + missing VAT leg) - account_override description asks the agent to state the actual affärshändelse in notes when overriding (BFL 5 kap description concern) - eventBus.clear() in the two new test suites (CodeRabbit minor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent Round-2 Swedish compliance finding: the class-2 VAT drop did not cover margin-scheme (VMB) accounts in class 3/4, which are the override's flagship use case, so a forgotten vat_treatment attached the category default standard_25 and booked an ingående-moms deduction on a transaction where input VAT is not deductible (ML 2023:200). applyAccountOverride now takes explicit VAT intent (vat_treatment or vat_amount present) and books GROSS with no auto-VAT line without it: forgetting the flag under-deducts (lawful), never over-deducts. Both call sites (MCP staging preview, commit core) derive the flag the same way; the tool description states the enforced behavior. Deliberate divergence from v1 REST recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: move stray decision-log entry to the root DECISIONS.md The round-2 entry was appended from the wrong working directory and landed as lib/bookkeeping/__tests__/DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
05380ddf54 |
feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9be20b4274 | test(bookkeeping): use hotel account in VAT fixture (#1587) | ||
|
|
78a581bca1 |
fix(import): guard against CP437-as-CP1252 mojibake entering via pre-decoded SIE text (#1569)
* refactor(arcim-migration): remove the dead gateway SIE export path fetchSIEExport and SIEExportFile have had zero callers since the direct provider clients replaced the Arcim Sync gateway (#181, #718). The path returned SIE as a pre-decoded string, and the gateway's decode of CP437 bytes as windows-1252 is what wrote the 2026-03-17 mojibake into posted entries. Deleting it makes the string-typed SIE fetch impossible to re-wire; a comment marks the grave. The consent lifecycle and entity accessors stay untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): warn when SIE text carries CP437-as-CP1252 mojibake The 2026-03-17 migration wrote mojibake ("L"neutbetalning"-style C1 specials) into posted entries because the retired gateway handed the /import-sie handler an already-decoded string: byte-level encoding detection never saw it, and nothing downstream checked. The live bug is gone; this is the tripwire so the signature can never land silently again. - lib/import/sie-artifact-scan.ts: pure scanner over parsed SIE account names and voucher/line descriptions, reusing hasCp1252Artifact from charset-repair; flags at >= 2 hits so a lone legitimate curly quote or apostrophe cannot false-positive a whole file. - arcim-migration /import-sie: warn-never-block; the Swedish warning rides on result.warnings, which the workspace UI already renders, plus a server-side log.warn. - wizard parse route: same scan, surfaced through the existing parse-issue warnings card in the preview, pointing at the first affected line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): pin the reported gateway mojibake strings Adds the four strings reported from the affected company's journal as reverse_cp437 cases (all reverse losslessly) plus a false-positive guard: space-padded typography must never route into the CP437 reversal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
27383fd02b |
fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker (#1543)
* fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker The account combobox in the inbox book-directly flow was fed only the company's active chart, so typing a prefix like 65 showed just the two activated 65xx accounts and a search for 6540 found nothing, which reads as the account not existing. Pass the cached BAS catalogue (same pattern as JournalEntryForm) so every standard account is searchable; picking a not-yet-activated account flows through the existing ActivateAccountsDialog rail at booking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): log BAS catalog fetch failures Compliance swarm finding (SOC 2 CC7.2): loadBasCatalog swallowed fetch errors silently, leaving catalog-load failures unobservable. Log inside the client's catch, which is the only place the error actually surfaces: callers' own .catch handlers are unreachable since the shared promise already resolves to an empty list on failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): unit-cover loadBasCatalog fetch, fallback, and retry CodeRabbit finding on PR #1543: the catalog client had no focused coverage. Tests assert the success path with promise caching, empty-list fallback with logging on non-OK responses, missing data field handling, and cache clearing after a failure so the next call refetches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7cf0e34434 |
feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fe1ff8649b |
fix(bookkeeping): drop non-standard account 2012, book EF F-skatt on 2013 (#1409) (#1489)
Primary-source check against bas.se (BAS 2026 v2): the official kontoplan has no account 2012; the enskild firma equity block is 2010, 2011, 2013, 2017, 2018, 2019. 2012 'Avräkning för skatter och avgifter' is a program convention (Visma, Bokio, Björn Lundén), not standard BAS, and a non-standard account in BAS_REFERENCE leaks via the backfill into charts, SIE export and SRU filing. - remove 2012 from class-2-equity-liabilities.ts, with a tombstone comment - migration retargets 'Preliminär F-skatt (EF)' lines 2012 -> 2013 (system row plus any clones still carrying the seeded shape) - pin 2012's absence in bas-ef-equity-accounts.test.ts (2113 precedent) - correct the swedish-year-end-closing references that motivated #1388, regenerate atom seed migration Companies whose charts already got 2012 backfilled keep it: existing history stays valid; only future template use books 2013. Closes #1409 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3ff12faa77 |
fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20% (#1494)
* fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20%
applyTemplate() extracted VAT out of the total (rate/(1+rate)) for every
vat line, including the fiktiv-moms pair of reverse-charge templates.
Under omvand skattskyldighet the supplier charges no VAT, so the total IS
the beskattningsunderlag: on 807.99 kr the seeded EU-purchase template
booked 161.60 kr (20%) on 2614/2645 instead of 202.00 kr (25%),
understating Ruta 30-32 and Ruta 48 on the momsdeklaration.
Fiktiv-moms lines (2614/2624/2634 output, 2615/2625/2635 import,
2645/2647 input) now compute amount x rate on top of the base.
deriveTemplateLinesFromBooking ("Spara som mall") gets the mirror fix:
RC legs no longer inflate the derived total (they net to zero), and RC
rates snap against the base, so a correct RC booking round-trips.
Counterparty/SIE learned patterns already strip RC legs and regenerate
them via generateReverseChargeLines with the gross base; those paths
were correct and are unchanged.
User-reported: "Er automatiska utrakning ar pa 20%, inte 25%".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bookkeeping): use roundOre for template VAT rounding (guard ratchet)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(bookkeeping): clarify fiktiv-moms comment: total is the base, booked amount is the VAT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
70845edf69 |
feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name
Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:
- transactions.transaction_method (text + CHECK closed vocabulary: card,
transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
bank_transaction_code / proprietary_bank_transaction_code evidence
columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
explicit source method (Stripe txn.type) > trailing Swedish channel
phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
as original_description; dedup is untouched (external_id is date+öre,
the content bridge reads original_description and is prefix-based, and
a trailing strip leaves a prefix). Enable Banking passes the codes
through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
(+ MCC and Stripe prefixes) and strips unedited titles; user-edited
titles are never rewritten.
- mapping-engine also matches original_description so user rules written
against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
"Betalsätt: Kortköp" etc. (sv/en), making every classified row
expandable.
A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): anchor counterparty-template identity on original_description
Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.
Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.
Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): review follow-ups on method classification
- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
refinement on the proprietary code beats a bare family match on the
ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
that only match original_description, including the invalid-regex
substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
mapping to 'fee'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): scope method classification to feed rows + adjective guard
Multi-bank risk hardening before the backfill ships:
- Feed-row scope: classification and title stripping now require a real
import feed (import_source present, not manual/mcp), both at the
ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
every backfill statement. User-authored titles like "Egen insättning"
on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
is skipped, so "Egen insättning" stays whole even on bank-feed rows;
the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
phrase means no method and no rewrite, so the worst case for any bank
whose vocabulary we have not seen is the status quo.
Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations after rebase
Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): bump transaction_method versions past prod's max
Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker
- TransactionInboxCard: row-level Enter/Space handling now ignores events
bubbling from nested controls, so keyboard activation of Bokför / the
overflow menu is no longer cancelled by the (now much more common)
expandable row.
- Feed predicate parity with isImportedTransaction(): a live
bank_connection_id marks a feed row even when import_source is unset
(the oldest PSD2 rows predate that column), in both the ingest
classifier and every backfill statement: those legacy rows now get
classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail
Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): enforce, not assume, original_description preservation in the title strip
The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the compliance-review triage of the backfill's booked-row title strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
|
||
|
|
cd344b6dbb |
fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries check_balance_on_post only fires on the draft-to-posted UPDATE transition, so any code path that INSERTs a row with status 'posted' directly skipped balance validation entirely. The invariant sum(debit) = sum(credit) on every posted entry was DB-enforced only for the engine's commit lifecycle. Add check_balance_on_posted_insert, a deferred constraint trigger on AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing check_journal_entry_balance() function, which already handles the journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics let an atomic transaction insert header and lines together; zero-line and unbalanced posted inserts are rejected at constraint evaluation. All existing checks stay intact; this only adds coverage. The one first-party posted-INSERT path outside an RPC, the sandbox seed, now books through the bookkeeping engine (createJournalEntry) instead of raw inserts. SIE import already inserts header and lines in a single transaction via its structured RPC and passes unchanged. pg tests cover the new path (zero-line rejected, unbalanced rejected at SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and existing posted-entry fixtures move to a transactional insertPostedJournalEntry helper so they stay valid setup. Fixes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): insert list-filters pg fixtures in one transaction The list-filters suite (landed via a sibling merge) inserted posted headers with getPool().query, where each query autocommits: the deferred check_balance_on_posted_insert constraint fired at the header's own commit with zero lines and correctly rejected the fixture. Header and balanced lines now share one BEGIN/COMMIT so the constraint evaluates the complete entry, mirroring the insertPostedJournalEntry helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(seed): insert journal headers as drafts, post after lines land check_balance_on_posted_insert (renamed to apply-time version 20260806130000) rejects a posted header whose transaction has no lines. PostgREST autocommits each request, so every seed path that inserted posted headers first would die with "has zero total": the sandbox seed (ledger history, invoice vouchers, salary vouchers), seed-demo-account and seed-export-data. All now insert draft headers, insert lines, then flip to posted so check_balance_on_post validates the finished verifikat. The sandbox seed keeps its documented no-events design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve a preset committed_at on draft-to-posted transition set_committed_at() stamped now() unconditionally, so the seed flows that post backdated drafts lost their historical booking timestamps and every demo verifikat read as booked today (CodeRabbit finding on PR 1439). Stamp only when committed_at is NULL: the engine path (drafts carry no committed_at) behaves exactly as before and a posted entry still always has a committed_at; an explicitly supplied value now survives posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve preset committed_at only for trusted roles The IS NULL guard alone (20260806150000, never shipped; replaced by 20260806160000) let any RLS-permitted member backdate committed_at through PostgREST by presetting it on a draft and posting, which the Swedish accounting review flagged: committed_at is what the BFL 5 kap timeliness checks and behandlingshistorik treat as the genuine transition time. Preset values now survive posting only for service_role/postgres/supabase_admin; authenticated and anon writers always get the now() stamp. Consequence: the sandbox seed (runs as the requesting user) gets committed_at = posting time, accepted and documented in the route; the demo scripts run as service_role and keep their backdated history. pg tests cover all four paths, with the upper timestamp bound CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): restore superseded migration so the preview tracker stays consistent The preview branch had already applied 20260806150000 when the previous commit deleted the file, orphaning the preview's migration tracker ("Remote migration versions not found in local migrations directory"). Restored with a header explaining it is superseded in the same deploy by 20260806160000, so the unguarded semantics are never live on their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): decide committed_at trust by JWT claims, not current_user The Swedish review found the current_user guard bypassable: commit_journal_entry is SECURITY DEFINER and granted to authenticated, so inside it current_user is the function owner and a member could preset a backdated committed_at on a direct-inserted draft and launder it through the RPC. The guard now reads the JWT claims role (same primitive as the RPC's own tenant guard): preset values survive only for service_role or claim-less backend connections; authenticated and anon callers are always stamped now(), on both the direct UPDATE and the RPC path (new pg test). Both migration files now carry the identical final body so no unguarded intermediate exists as a standalone applyable unit. Behandlingshistorik logging of trusted overrides is follow-up #1444. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d73f288927 |
fix(supplier-invoices): reverse credit notes on paid kontantmetoden invoices (#1430)
Under kontantmetoden the credit flow skipped the reversing verifikat entirely, gated on accounting_method === 'accrual' in all three surfaces (dashboard route, v1 route, pending-operations commit). That is right only while the original is still UNPAID: nothing reached the ledger, so there is no entry to reverse and recognition waits for cash. But a PAID original was already booked by its payment verifikat (expense + 2641 ingaende moms). Crediting it marked the invoice 'credited' with zero accounting trace, leaving both the cost and the moms deduction overstated and nothing to link a later refund back to. Adds supplierCreditNoteNeedsJournalEntry(), the mirror of the customer side's creditNoteNeedsJournalEntry(): reverse whenever the original actually reached the ledger, whatever the configured method. createSupplierCreditNoteEntry's existing shape already suits the cash case, the 2440 debit leaves a claim on the supplier that the refund clears, just as the customer side leaves a 1510 credit for a refund owed. The v1 route's GDPR-minimised projection dropped exactly the booked-ness columns this needs; they are restored with a comment explaining why, since status alone misses a part-paid-but-booked original (rows predating #1413). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8f38baca05 |
fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally developed intangibles, but the asset register defaulted the immaterial category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no framework gate beyond K3_REQUIRED_FOR_COMPONENTS. - New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH /api/assets/[id]: when accounting_framework is not k3, reject any asset whose resolved asset or accumulated account is flagged k2_excluded in the BAS reference. Resolution mirrors the service defaults so category defaults cannot sneak onto 1010/1019; patches that leave category and accounts untouched skip the gate so legacy assets stay editable. - Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code registered in structured-errors.ts with Swedish and English messages. - CreateAssetDialog: non K3 companies now book immaterial assets on the purchased pair 1090/1099 with a quiet hint that egenupparbetad utveckling requires K3; K3 companies picking immaterial see a note about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par. - Route tests: K2 rejected on 1010 defaults and explicit overrides, K2 accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents and a gate skip regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4 citation. The flag also covers accounts excluded from K2 for unrelated reasons (1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940, 8290 to 8480), so those users got a factually wrong legal citation in a compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS range refinement, so an explicit bas_asset_account override outside the category range hits the gate before updateAsset() raises its range error. - k2ExcludedAccountMessages() now picks the wording from what actually triggered the gate. The boundary is derived from the chart itself (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a flag change in bas-data moves the boundary with it. Other Ej K2 accounts get a generic message: the chart marks it Ej K2 and it requires K3, with no invented paragraph reference. - Both messages are bilingual (message_sv / message_en, registry shape) and the routes now return message_en alongside message. - The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation too: it is the code level fallback for every k2_excluded account. - Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4, 1370 must not), plus a guard unit test asserting the derived group and that no non group 10 Ej K2 account ever cites 10.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): let K2 companies register acquired intangibles, server side The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24, "Only acquired intangibles may be recognized"). But asset-service still resolved category 'immaterial' to 1010/1019 for everyone, and only CreateAssetDialog compensated with an explicit 1090/1099 override. EditAssetDialog sends just the changed fields and has no account inputs, so a K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit the defaults, got a 422, and was told to switch the company to K3, which would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole arsredovisning. The asset stayed on 1220/1229 and kept being presented as a tangible asset. - defaultAccountsForCategory(category, framework) is the single resolution point: immaterial resolves to the acquired pair 1090/1099 unless the framework is k3, every other category is unchanged. Both createAsset() and updateAsset()'s category realign go through resolveDefaultAccounts(), which reads companies.accounting_framework only for the intangible category and throws rather than guessing when that read fails. Explicit overrides and the realign-skip semantics are untouched. - Both routes resolve gate accounts through the same function, so the check mirrors what the service will persist. A K2 company on the defaults now passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s. - CreateAssetDialog drops its now redundant client override so the two surfaces cannot drift; the hint text stays. - The 422 no longer asserts the company's framework (the companies read behind it discards its error, so a transient failure would assert it against a K3 company) and no longer proposes a regelverk change. It states that the account is reserved for egenupparbetade utvecklingsutgifter, which require K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped to the kontogrupp 10 group, derived from the chart as before. sv and en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
31fba5e8f9 |
fix(accruals): carry origin dimensions onto dissolution entries (#1419)
* fix(accruals): carry origin dimensions onto dissolution entries
A project-tagged deferred invoice line lost its dimensions bag on every
monthly dissolution: the schedule never stored the bag, so dissolution
lines booked untagged and the per-project P&L drifted from the origin.
Persist the merged bag (invoice default_dimensions with the item bag on
top, same merge the origin generators use) on accrual_schedules and
attach it to BOTH dissolution lines: both origin generators tag the
interim 17xx/29xx line too, so per-dimension views of the interim
account keep netting to zero. Pre-existing schedules stay at '{}' and
keep today's untagged behavior; backfill is a separate follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(accruals): let dissolutions post when the tagged dimension value is archived
Carrying the origin's dimensions bag onto dissolution lines pulled every
monthly periodisering into validateEntryDimensions. With dimensions_enabled,
a project archived after the origin invoice was booked made the validator
reject the dissolution: the accrual service caught the rejection, wrote
last_error and left the installment pending forever. The remaining months of
prepaid cost would never reach 5xxx/6xxx, the interim 17xx/29xx account would
stay overstated, and the trial balance still balances, so no year-end check
fires and the arsredovisning is filed with understated cost.
Add a second, narrow exempt set (DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES =
{'accrual'}) with an isDimensionValidationExemptSource() helper, and skip the
soft registry validation for it in createDraftEntry. A dissolution is the
mechanical continuation of an already-approved, already-posted decision: the
same category as a storno, which the engine already bypasses.
The tag is kept, never stripped: an archived value still exists in the
registry and the cost genuinely belongs to that project. The rules set
(required/default/fixed) stays untouched, and the other two
validateEntryDimensions call sites keep validating: updateDraftEntry is a
user edit of an editable draft, and replaceOpeningBalanceEntry rejects any
source_type other than opening_balance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|