e85eac317ce01c2fb97f6109dedb43a4da152dee
1055 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e85eac317c |
feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* feat(arsredovisning): manual override for medelantal anstallda (Not 2) Why the problem occurred: the ÅRL 5:20 § note was derived only from the employees table, and most aktiebolag that book salary never create a Löner employee record (hand-booked salary, SIE import, migrated history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have no employees rows, so their note reads "inga anställda". The reporting company had one row created the same day with a start date halfway through a July-June year: 181/365 = 0.5 FTE rounds to 0. What was removed or simplified: nothing removed. One resolver (resolveMedelantalAnstallda: override, else FTE average) now feeds the K2 note, the K3 note and the iXBRL fact, so no reader can pick a different number. The override sits on arsredovisning_narratives next to the other ÅRL 5 kap. disclosures and rides the existing narrative GET/POST route, service and page save. Why this and not the proposed one: the request asked support to "enable override of Not 2" as free text. A whole number keeps the statutory sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free text would allow a non-compliant note. Rounding 0.5 up globally was rejected (changes every company's note silently, does nothing for companies with no employees rows), as was backdating the hire date (fixes one company, misstates the fact). The iXBRL input also reads the previous period's override so the jämförelseår column shows what last year's document showed. Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and is applied and tracked on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD * fix(arsredovisning): size-threshold metrics use the medelantal override too Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read the FTE average from the employees table, so the ÅRL 1:3 § större- företag test and the K2 relief thresholds could disagree with the figure Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no employees rows and an override of 60 both years, balansomslutning over 40 MSEK, would have validated as K2-eligible while its own document said 60 employees. Fix: the metrics resolve the employee figure the same way the note does (current period override from report.disclosures, previous period via getMedelantalOverride on that period's narrative row). previous_period on ArsredovisningData now carries the period id so the lookup needs no extra fiscal_periods read. The iXBRL employees-error fallback keeps the previous period's override instead of blanking the jämförelseår. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
2303f75a7b |
fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage Why the problem occurred: the supplier register was written in three spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored whatever the caller sent, the AI extractor emits bare digits) while matchSupplierByIdentity compared raw strings with .eq(). The canonical rule existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey, the extractor's toOrg10) and nowhere on the path that decides a match, so every AI-extracted invoice from a hyphen-registered supplier missed the strongest key and fell to exact-name matching. Prod holds 1738 hyphenated rows against 493 bare ones. What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10 of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two other copies. The matcher scans the company's suppliers with an org_number and compares keys, the same shape as its vat_number branch, so rows written before the backfill (and self-hosted instances that never run it) match too. CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier schema store the key; the form renders it through formatOrgNumberDisplay. A backfill migration strips the formatting from existing rows, skipping migration-reset source companies. Why this and not the proposed one: the issue's third layer (CHECK plus a unique index) would fail to create on prod, which holds 94 duplicate (company_id, key) groups across 18 companies, one of them 124 rows under a single placeholder-looking number; that needs a merge decision first and is filed as #2404. Rejecting anything that is not 10 or 12 digits on write was also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL, FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is canonicalised and anything else is stored as typed. Luhn stays lenient on suppliers because two rows with the same mistyped number are one supplier and parties is Luhn-strict at promotion already. Fixes #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key Skeptic pass on the previous commit. Three refutations, all confirmed: 1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to 6012579001, another company's identity, on every write path and in the backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A Belgian BE0123456789 lost its country letters the same way. The key now strips only hyphens and spaces and unprefixes 12 digits only behind 16/18/19/20; everything else is null, stored and compared as typed. The migration carries the same rule. 2. The supplier list search, the v1 ?search= filter and the list column all used the raw stored value, so a user searching 556677-88 after the backfill found nothing. Both searches now compare without separators and the column renders XXXXXX-XXXX. 3. Storage was not canonical on every path: the CSV import and the provider migration orchestrator wrote as typed and keyed their re-sync dedup by the raw value, so a Fortnox re-sync sending 556677-8899 would have duplicated the now-bare row. Both write and key through orgNumberKey. Also: the matcher scans live suppliers only, so a register holding an archived hyphenated row next to its live replacement resolves to the live one instead of whichever id sorts first. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical CodeRabbit findings on PR #2405, all verified against the code: - The supplier list rendered through formatOrgNumber, which strips letters and would show BE0123456789 as 012345-6789; it now uses formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone. - The CSV import dedup fell back to digits-only, so BE0123456789 and FR0123456789 collided; the fallback is now the value as typed, in both the parse preview and the execute route. - The provider migration's supplier-invoice stub map was keyed by the raw provider value while the stored row was canonical, so 556677-8899 and 5566778899 on two invoices produced two stubs; the key goes through orgMapKey like the other maps. - v1 response examples show the stored 10-digit form; the request example keeps the hyphenated input. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * docs(api-skill): regenerate suppliers reference for the canonical org_number example Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
4e8d649b2f |
fix(invoices): Ej skickade view and a prompt before downloading a draft-stamped PDF (#2401)
* fix(invoices): show finalized-but-unsent invoices as their own view and ask before downloading a draft-stamped PDF
Two user reports, one hidden state: an invoice that went through Granska
& skapa has an F-number but its DB status is still 'draft' until it is
marked as sent (and booked). The list lumped those rows under Utkast, and
"Ladda ner PDF" handed out the UTKAST-stamped render with no warning, which
users then mailed to customers.
Why it occurred: the status column carries two meanings for 'draft'
(unnumbered draft vs numbered, unsent invoice) and every surface decided on
its own how to read it. The list badge knew the difference ("Ej skickad"),
the tab predicate and the PDF download did not.
What was simplified: the tab predicate moved out of the page into
lib/invoices/invoice-list-tabs.ts as one function used by the rows, the
per-view counts, the status sections and the row badge, so the four cannot
drift. The ?status= alias parsing collapsed into the same module.
Why this and not the proposed shapes: a real 'issued' status in the DB
would touch MCP, the v1 API, reports and SIE for a distinction that
invoice_number already carries. Removing the UTKAST stamp from numbered
drafts would be wrong: an unbooked invoice is not issued. So the UI splits
the state (Ej skickade view, ?status=unsent, ?status=godkanda alias) and the
download asks first: "Bokför och ladda ner" (or "Markera som skickad och
ladda ner" for cash-method companies and offerter) runs the existing manual
mark-sent dialog and then downloads the issued document; "Ladda ner utkast"
still works. The manual mark-sent toast now offers "Ladda ner PDF" too.
Fixes #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
* fix(invoices): skeptic round on the draft-download prompt
Four refutations on 352d4bafc, all confirmed in code:
- Proformas (and any non-faktura) in an accrual company were told "Fakturan
är inte bokförd" and offered "Bokför och ladda ner"; mark-sent never books
a proforma. The label predicate is now issuesByBooking = booksOnIssue &&
isRealInvoice, shared with the existing primary button, which carried the
same "och bokför" promise on proformas.
- A partial mark-sent success (PDF archive, periodisering or delivery history
failed) still ran the chained download, and its toast evicted the warning
(one toast at a time). onSuccess now carries `partial`; the chained
download is dropped on a partial result, matching the toast action.
- Numbered följesedlar are stamped UTKAST too (pdf-template does not exclude
them) but the decision skipped the prompt. They now get the prompt; the
issue action is their own status flip, so updateStatus reports success and
the download is queued only after it.
- The download queue was a boolean bound to "whatever invoice is mounted";
the detail pager keeps the page mounted across ArrowLeft/ArrowRight, so a
step could download the neighbour or leave the queue armed. The queue now
holds the invoice id and is dropped when a different invoice is shown.
Refs #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
* fix(invoices): review pass on PR #2401
CodeRabbit, all confirmed against the code:
- The PDF preview (Visa PDF) bypassed the draft prompt; the browser viewer
has a save button, so an unwarned preview is an unwarned download. The
preview now runs the same decision; the prompt's draft button honours the
original intent ("Visa utkast" opens the viewer, "Ladda ner utkast" saves).
- updateStatus lost its isUpdating reset when both branches started
returning; moved to finally so a failed refetch after mark-sent does not
leave the page's buttons disabled.
- A cancelled credit note matched both the Kreditfakturor and Makulerade
views; the credit view now excludes cancelled rows like every other view.
Declined: the DECISIONS.md date (2026-09-08 is the local date the decision
was recorded; the bot compared against UTC) and the docstring-coverage
warning (not a repo gate).
Refs #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
477b59453f |
fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string (#2398)
* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string
Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").
One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card
Skeptic findings on 4a30bb3f3:
- The repair migration would have aborted on prod: 110 of the 6,356 rows
belong to a migration-reset source company, whose transactions are
immutable by trigger (transactions_block_migration_reset_source_mutation).
Same failure as 20260903170000. Those rows are now excluded; nothing reads
the column back for an archived company.
- With the code description reaching the keyword tables as a string,
"Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
precedes withdrawal in both tables (and in the Connect mirror), matching
what TRAILING_PHRASES already says about the same phrase.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed
coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
fdcb7d937e |
feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5d735097a1 |
fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)
* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only markPeriodClosedExternally decided "migrated" by asking whether the period had any non-imported verifikat at all. That was a proxy for the thing the guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap), and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE import. With the next year's IB already imported, the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all. The guard now asks the ledger the real question: are there lines on result accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. The refusal message names what was found and why the normal year-end is the remedy. The loaded årsredovisning view also gets the FyPicker in its header. The no-period branch auto-jumps to the remembered scope (or the newest year) before its own picker is ever seen, so a user whose scope pointed at the historical year had no way to reach the current year's årsredovisning except by changing the scope on some other page and coming back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q * fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8 Triage of the swedish-compliance review: - Balance-sheet-only native years bypassing the bokslut: partly real. The result transfer is not the only thing the normal year-end does; it also posts the next year's IB. A natively bookkept year with only balance- sheet vouchers (dormant AB, aktiekapital deposit only) could have been klarmarkerad and its balances would never have reached the next year. The leg now additionally requires the next period to carry IB already, which is exactly the shape where the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB the normal year-end works and stays the remedy, and the message says so. The suggested IB-correction tag on entries was not adopted: the ledger already answers the question, a tag would be one more concept to know. - Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9 interna poster no longer count as result accounts. Storage format is the 4-digit BAS string every classifier in the codebase already relies on (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as those. Tests: next year with IB passes, next year without IB refused, no next year refused, lt('account_number', '9') asserted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f047c3d7d1 |
fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user (#2373)
* fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user The Skatteverket OAuth callback answered NEXT_PUBLIC_APP_URL regardless of where the flow started, so on a white-label brand domain the popup's postMessage was dropped and the fallback redirect landed on the wrong origin without a session. On hosted, the initiator check from #2155 was bypassed by design because the registered callback host carries no app cookies, so a lured victim's BankID-authorised tokens could be stored under the user who started the flow. Flow state moves from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000), consumed atomically. Hop 1 on the registered OAuth host consumes the state, stashes the provider code or error encrypted under a separate handoff id and 302s to the recorded origin; hop 2 there claims the handoff bound to that origin, requires the initiating user's session, re-checks membership and exchanges the code. Error pages keep the tab open. The self-hosted single-hop and the connector broker branch keep working. The hosted no-session exception, the legacy cookie-user fallback and the optional PKCE verifier are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): decide the callback hop by host, close the tab when the flow is unknown Skeptic findings on #2373. The hop comparison and the handoff claim used the request origin including its scheme, which Next derives from x-forwarded-proto; a self-hosted proxy that forwards Host without it (or rewrites Host to the upstream address) made every connect end in a state error. Hops are now compared by host only, and the handoff is claimed for the validated origin the host resolves to, scheme from configuration. Error pages answered before the flow row is known (unknown, expired or replayed state or handoff) post to a guessed origin that a brand opener never hears; they now close the tab so the panels' closed-tab watcher resets them instead of leaving Connect disabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * test(skatteverket): mock resolveBrandResultByHost for the merged login-redirect resolver Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): bind the initiator before the flow is spent Superagent P2 on #2373: hop 2 deleted the handoff before the session and membership checks, so a signed-out or wrong-user arrival burned a live consent. The finishing hop now peeks the row for its initiator, binds the completing session to it, and only then consumes atomically. A session-less arrival is sent to /login on the initiating origin and resumes into the same callback URL; a different user is refused with the row left claimable for the initiator. The handoff TTL is five minutes so a sign-in fits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): check membership before the flow is spent, answer the callback page on a failed mint Second review cycle on #2373. Superagent: the company-membership check ran after the consume, so a revoked initiator burned the provider code on the way to being refused; it now runs inside the pre-consume binding. CodeRabbit: a failed handoff mint escaped as a framework error page the opener never hears; it now answers the callback error page on the initiating origin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a769bc9d03 |
feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow BL issued our integration activation key on 2026-09-07. With BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i Lundify": the customer logs in at Lundify, picks the company and accepts the scopes, and Lundify returns the company's User-Key to our callback as publicKey with our one-time state echoed as extra. The manual User-Key field stays as a folded fallback for companies that activated inside Lundify already. The callback folds publicKey/extra into the OAuth-shaped locals, so the atomic state consumption, initiator binding and white-label handoff run unchanged; only the final step differs: submitProviderToken (the same client-credentials probe as the manual field) instead of an OAuth code exchange, owned by the consent's company read from the server-written row. consumeOAuthState/consumeHandoff now return that company id. Closes #2323. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L * fix(migration): reset the previous connect attempt before a new provider request Review follow-up: a failed /connect used to leave the earlier consent id and one-time activation URL in place, so the step kept offering a link that completed the previous consent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8aa5d54510 |
chore(domains): add solbo.accounted.se to the production host inventory (#2378)
Solbo was provisioned on prod on 2026-09-01 (team ce8993e5, brand 6e3c78af) but never added to CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. The namespace rule already protects the host; the set is the checked-in inventory the tests pin host by host, and it was stale. Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d29a5bda14 |
fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS Password reset, invite, email change and signup links now resolve the request host against brands.domain server-side. The env var was a second copy of that registry compiled into the browser; every new brand needed the row, the env var, the GoTrue allowlist and a redeploy, and two partners shipped with the env var stale, so their reset mails went out canonical-branded to the canonical host. - New POST /api/auth/password-reset: the login page no longer calls GoTrue directly, so the browser carries no domain list. - lib/domains/trusted-app-origin.ts is async and registry-backed; it also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so previews keep sending links to themselves. - Signup shares the same resolver instead of following the raw host. - Docs and .env.example describe the single registry; GoTrue keeps the redirect allowlist as backstop (hosted: *.accounted.se wildcard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): await the async origin resolver in the billing routes merged from main PR #2370 added resolveRequestAppOrigin callers in billing/checkout and billing/portal after this branch made the resolver async. Await them and move their tests from the removed env var to the brands mock; update the login source-assert test to the server-routed reset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs Skeptic and CI findings on #2376, one pass: - A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR, 503, retryable) instead of falling back to the canonical origin: a canonical link is the wrong-brand mail this PR removes. Password reset and email change answer 503 themselves; withRouteContext routes map the code. - A local canonical (dev) trusts other local hosts and ports on the same scheme, so lane servers on 3001-3003 confirm signups on themselves. - GoTrue matches the full redirect_to including the query and `*` stops at `.` and `/`: docs and decision line now prescribe https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**. - The Turnstile contract test asserts the server-routed reset forwards the captcha token (it still asserted the removed browser call). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7a30f623ba |
fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions
A user reported five things about the invoice PDF after the English
translation shipped:
- The yellow draft banner pushed the whole document down, so a draft
previewed differently from the final invoice. It is now a small stamp in
the page's top margin (absolute, fixed), repeated on every page, out of
the flow.
- Table rows, totals, the payment box and the notice boxes could split
across a page break, and a section heading could be left alone at the
bottom of a page. Those blocks now carry wrap={false}; headings and the
table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
a line breaks. The editor field is now an auto-growing textarea; the PDF
and the on-screen views keep the line breaks; the Peppol item name
collapses them (single-line field).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF
Skeptic findings on the first commit:
- A word wider than its column was dropped from the page or overprinted the
quantity column, because the no-hyphenation callback gave react-pdf no
break point inside it. Words up to 16 characters still wrap whole; longer
tokens (URLs, e-mail addresses, references) break after separators and
every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
past the page edge. Line descriptions and notes are now kept together only
while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
the SIE export (one record per line). The accrual builder and the Peppol
item name share toSingleLine(); the SIE writer collapses line breaks in
quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.
Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row
Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.
The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): keep words whole by estimated ink width, not a character cap
Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): estimate word width from real Helvetica metrics
Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* docs(invoices): state the bundled-font width margin accurately
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together
- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
is the escape character. It now writes a literal backslash as two, and the
parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
at the description column, so it could split unnecessarily. It uses the
full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
its per-line breakdown carries the (possibly multi-line) descriptions. It
uses the same keep-together estimate as rows and notes, and its line texts
wrap whole words.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
c634cf9ae0 |
fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)
* fix(banking): return Enable Banking consent callbacks to the initiating brand host Enable Banking redirects every consent to the one canonical callback URL while browser sessions are per host, so a white-label user reached the callback signed out and was bounced to the unbranded canonical login. The pending row now records the allowlisted origin the flow started from, and the callback uses it for the login bounce, the success redirect and the denial banner. The brand host already holds the session, so its /login forwards straight back into the callback with cookies; the provider redirect URI stays canonical, nothing changes in the Enable Banking console. The shared login redirect helper also stops dragging a callback that arrived on a registered brand host to the canonical login. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz * fix(banking): reload the PostgREST schema cache after adding oauth_origin Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY, and without it PostgREST can reject the new column on connect until its cache refreshes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0016732232 |
feat(invoices): let the invoice list read by row tone without the status column (#2353)
* feat(invoices): let the invoice list read by row tone without the status column A user asked for green/yellow/red row tints in the fakturalista (#2215) so the list can be read without the last column. The need is real; the tint is not the answer the design system already gives: status colours are data, not chrome (convention 12), and DECISIONS 2026-08-03 already ruled out row tints for this list. Settled rows (paid, cancelled, credited) now recede to muted text as a whole row. Open rows keep the foreground and the overdue chip stays the one marker on its row, so three row classes read at a glance with zero new colour: grey is done, dark is waiting for money, dark with an ochre chip is late. Works in dark mode and the brand themes for free and never stacks with the hover or selection tints. The tone lives in a pure lib helper (invoiceRowTone, tested) and one Record in the page maps tone to class; a row tint or a left-edge bar is a three-string swap there if the founder prefers colour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * docs(decisions): record the first-principles choices of the 2026-09-06 evening issue batch Ten lines for #2215 #2351 #2218 #2220 #2312 #2184 #2293 #2239 #2331 #2332 (PRs #2353 to #2362), carried by this PR so the other nine branches do not all touch DECISIONS.md. 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> |
||
|
|
bf7773d74b |
feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)
* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184) A new company_settings row now defaults to the standard series set (A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor, E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura, M moms) instead of everything on A. The set lives once, as the exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test holds the column default equal to it and to the source_type CHECK. Existing rows are not remapped: the per-type settings form gets an "Använd standarduppsättningen" action that fills the set for review and save through the existing PUT, so the switch is a deliberate, audited act rather than a mid-year numbering change nobody decided. Payment rows bound to the other bokföringsmetod are dimmed, not hidden. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A voucher-series-defaults.pg.test.ts codified the pre-#2184 column default (every source type on A). Migration 20260906210500 replaces that default with the standard set, so the "freshly inserted row" case now asserts the representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP. The explicit-override case keeps proving a company's own layout replaces the default wholesale. No other pg or tool test asserted on the old map. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4fce2d7b94 |
feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0069a3f59a |
feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran Skatteverket decides per begäran but pays everything it decided that day in one transfer, so the bank row often equals no single open begäran and the 1:1 matcher from #2271 stayed silent; the settle service then refused the amount and the dialog told the user to split the transaction by hand. The candidate is now the exact covering set of 1..4 open begäran whose expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts, over the existing findExactCoveringSet, ambiguity-refusing). It is computed at read time from the open pool (inbox page, worklist, ingest), no hint column. Confirming books ONE voucher (debit 19xx, one 1513 credit per begäran) through the same writer, marks every begäran paid and links the row once; a bundle is always booked at exactly the decided sums. - match-rot-rut-payout accepts request_ids (1..10) beside request_id - settleRotRutPayoutRequestSet shares the single path's tail - createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged) - inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set - new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set Closes #2239 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle The bundle path booked every leg as fully paid and mirrored requested_amount onto every begäran's items, while the single path completes a begäran only when the leg covers requested_total and otherwise leaves it partially_paid for manual handling. A begäran Skatteverket decided at less than requested is a legitimate bundle member (its leg is the beslut, the exact-sum rule is unchanged), so the set path now computes fullyPaid per leg exactly like the single path, passes it to the shared attachSettlementVoucher, and mirrors only the fully paid legs; sibling hints are still cleared for every settled begäran, which carries a voucher and is no longer matchable either way. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4bc007cdeb |
fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2354)
* fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2351) The PS reads entries with status posted or reversed, so a stornoed EU invoice voucher kept its 3308/3108 credit while the storno that nets it was dropped: its source_type is 'storno' and no register row points at it. A makulerad EU sale was over-reported while the account-based ruta 39 was zero. Which invoice explains an entry is now resolved by one composed helper, getInvoicesExplainingJournalEntries (lib/core/bookkeeping/ journal-entry-references.ts): the engine's own source_id, the invoice-side rows, and the rättelse chain through correction_of_id / reverses_id, walked upwards under the MAX_CHAIN_WALK cap correction-chain.ts already uses. Parents outside the batch are fetched by id, company-scoped. The link columns are followed rather than the storno's copied source_id because correctEntry() copies none onto its storno or correction, and a storno's copied source_id is polymorphic (a bank row on a bank booking). getInvoiceReferencesForJournalEntries (the RPC mirror behind the underlag surfaces) is unchanged: storno and correction are not doc-requiring source types, so those surfaces have no such hole. - INVOICE_SOURCED_ENTRY_TYPES / LINK_LOOKUP_CHUNK move to the helper module (the set now names every engine type whose source_id is an invoice). - PS: one resolver call; reverses_id, correction_of_id in the select; the ZERO_NET_EXCLUDED text names makulering beside kreditfaktura. - Tests: resolver cases (chain inheritance, out-of-batch fetch, own link wins, mirror, cycle, cap) and PS cases (the issue's storno nets to ZERO_NET_EXCLUDED, rättelse chain, later-period storno, mirror, linked import, gone invoice). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * fix(bookkeeping): PR #2354 review: bound in-batch chain propagation at MAX_CHAIN_WALK getInvoicesExplainingJournalEntries attributed an in-batch chain of stornos/corrections all the way down through a recursive assign over waitingOn, while parents fetched from outside the batch stopped at MAX_CHAIN_WALK. The two paths now agree: every attribution carries its depth from the root (0 for an entry resolved by its own source_id or invoice-side link), propagation is an explicit queue instead of recursion, and a descendant more than MAX_CHAIN_WALK links below the root resolves to no invoice, both when it inherits from an already attributed parent and when it is reached by propagation. Test: an in-batch chain of MAX_CHAIN_WALK + 1 links, in both batch orders, attributes the links within the cap and not the one beyond it, with no parent fetch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6a5fd6cd00 |
feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)
A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
6906bc4aa2 |
feat(compliance): InvoiceRowsCompleted behandlingshistorik event for migrated invoice rows (#2312) (#2357)
Every migrated sales invoice whose rows complete_invoice_rows writes, from the migration wizard or the hourly row-completion pass, now leaves one InvoiceRowsCompleted row in processing_history on a new Invoice aggregate: the writer, the provider, the consent, the row count, and the header VAT split before and after when the pass rewrote it (BFL 5 kap 11 §, BFNAR 2013:2 p. 9.16). One run shares one correlation id. lib/invoices/complete-invoice-rows.ts is the one TypeScript call site for the RPC and the one emitter: it appends only on wrote = true, records nothing for already_filled or failed, and keeps the append best-effort (logged, eventId null) like every other processing_history writer. The wizard runs on the user's session client, so MigrationOptions takes a lazy createHistoryClient for the service role. Invoice numbers stay out of the payload (the personnummer guard would drop ten-digit ones). Migration 20260906210100 widens the aggregate_type CHECK with Invoice and registers the event type; pg test covers the catalog row, the aggregate, and that the CHECK still refuses unknown aggregates. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ebbe50c0f3 |
feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)
The supplier-invoice form asks who paid with the same control as the Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead of its own switch under Förval. A person paying is an utlägg: the route hands the invoice to registerExpenseClaim with the invoice's kontering as the claim's lines, so the verifikat and the expense_claims row come from the same writer as the Underlag pane, the person shows up under "Betala ut utlägg" on Hem and the bank matcher closes the debt. Employees book on 2820 with employee_id; the owner's blank name falls back to the shared label so Hem groups one person. Also routes a person-paid inbox document through the core route with inbox_item_id: the extension's convert endpoint never read paid_with_private_funds, so the old switch was silently dropped whenever a receipt was attached. The second entry generator, the Förval switch, the outline "Registrera & markera som betald" button and the duplicated owner/employee picker are removed; PayerChoiceSelect and the claimant fields move to components/expenses so core and the extension share them. Closes #2332 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6776cb4fc6 |
feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)
* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293) The bridge table said "ej matchad" and steered to Bokför when a Bankgirot aggregate was already booked as two or three unlinked vouchers. The booking doors have refused that double booking since #2300 and #2346 with detectExplainingVoucherSet; the view never ran it. - duplicate-payment-detection: split the set detector into fetch and pure steps and add detectExplainingVoucherSets, the batch form (one ledger scan, one anchor lookup, per-row verdict identical to the single detector; a voucher explains at most one row per call). ExplainingVoucher now also carries voucher_series and voucher_number. - reconciliation/covering-set-candidate (new): maps sets to proposals (0.95 same date, 0.85 within seven days), SEK accounts only, fails open. - items: open bank rows nothing explains 1:1 are searched before they land in unmatched_external; a hit lands in proposed with proposal.vouchers. - schemas: ReconciliationProposal.vouchers (optional, set proposals only). - AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that posts every voucher as a 1:N pair to the existing links route. - i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293) The set proposal field added to the reconciliation items response shape flows into the generated agent skill; regenerated with `npm run apiskill:generate`, which changes one line of references/banking.md. 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> |
||
|
|
fcda4a75dd |
feat(parties): fill customer and supplier forms from the register on a valid org number (#2355)
The registry lookup was built for rows that already exist (the detail page's "Hämta uppgifter" records facts on the row's party), so on the create form people typed what SCB already knew. Org number now comes first in both forms; a complete, check-digit valid number of a Swedish legal person is looked up once and fills name, address, postal code, city (and the VAT number where the form shows one) wherever nothing has been typed. A typed value is never replaced; a corrected number replaces only its own earlier fill. A personnummer never reaches the register (client key and server gate), and an environment without SCB credentials answers 503 once and the form stays quiet. - GET /api/parties/registry?org_number=: read-only route over the same SCB client, credential gate and registrySummary reader as the enrich route; writes nothing. - lib/parties/registry-form-fill.ts: registryLookupKey (one rule for what may be looked up) and registryFormFill (contactFill's untouched rule plus name and VAT number), pure and tested. - components/parties/use-registry-autofill.ts + RegistryAutofillNote: debounced, once per distinct number, skips the number an edit dialog opened with, one muted line under the field. - Adressrad 2 is now an input on both forms: the register's c/o goes on line 1 with the street on line 2, as on the row, and both edit dialogs already passed the column in. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
3b9daf2606 |
fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the payout RPC applies, so "Jakob" and "jakob " are one person with one Att göra row and one exact-amount match. - The inbox pages through every registered claim (fetchAllRows) before pairing, so a long backlog can never understate a person's debt. - A foreign receipt's VAT field is locked at 0 and 0 is what is submitted. - Transport failures in the one-click and picker confirms show the destructive toast instead of failing silently. - The open-claims flag is set only after the stale-fetch guard, and a payout match decrements the inbox count like every other row exit. - Test: reset the live-link mock before the bank_line junction case. - Wording: "Återbetalning av utlägg". Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
92a734f2b3 |
fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC (#2350)
* fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC Rows marked as business before categorize failed closed (#1990) but never given a verifikat sit as is_business = true with no anchor in any of the three booking locations. The worklist predicate is is_business IS NULL, so they are unbooked and invisible: silent missing lopande bokforing. repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked, p_actor, p_correlation_id) lists the stranded shape (dry run, default) or, for one company, resets the same triple the engine's storno path resets (is_business, category, reconciliation_method) so the rows return to Att bokfora. The UPDATE re-asserts the full predicate in the same statement, never touches a journal entry, and writes one BankTransactionStrandedRepaired behandlingshistorik event per row in the same transaction. service_role only; a write needs a company id and an actor. scripts/repair-stranded-categorized-transactions.ts prints the per-company breakdown split by sandbox and lock state, and writes only after a typed confirmation that repeats the row count. The prod run is a founder decision per company and is not part of this change. Refs #2057 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(transactions): leave locked-period rows alone by default in the stranded-row repair Swedish compliance review on #2350: a row returned to Att bokfora inside a locked or closed period cannot be booked in place (BFL 5 kap 5 § keeps closed periods on the rattelse track), so reopening it for triage must be an explicit operator choice. p_skip_locked now defaults to true; the script lists those rows and resets them only with --include-locked. The pg test covers both the default and the explicit override. Refs #2350 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> |
||
|
|
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> |
||
|
|
7448490fb7 |
fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) The invoice-to-verifikat link is written on the invoice side only (invoices.journal_entry_id, invoice_payments.journal_entry_id), while the missing-underlag predicate and the periodisk sammanstallning resolved the invoice from the entry's own source columns. A SIE-imported sale matched to its invoice afterwards therefore kept warning "Underlag saknas" and was left out of the EU sales list, although the account-based momsdeklaration showed it and the verifikat page already listed the invoice as its underlag. - verifikat_without_documents / transactions_without_documents: customer- invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row; new migration 20260906135702, pinned by a pg-real test. - getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used by the journal-list filter and bulk exempt, /api/documents/counts (new invoice_references map) and the transactions list; the push cron mirrors it with its global reads. - Journal list: no "Underlag saknas" chip for a covered entry, matching the engine's own invoice rows and the verifikat detail page. - Periodisk sammanstallning: entries fetched by their EU-revenue lines and attributed through every link (engine source_id, invoices.journal_entry_id, invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment entries are filed too, which the old source_type filter dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review) - The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron) now require an ISSUED invoice: status not in ('draft', 'cancelled'), the schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_ STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the pg test pins a draft-linked and a cancelled-payment entry as still missing. - Periodisk sammanstallning: one verifikat linked to invoices of different customers is no longer attributed to the first invoice; it is left out of the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT naming the voucher, the customer count and the amount. Same-customer settlements are filed in full. - Transactions list: a failed invoice-reference lookup leaves that chunk's verdict unknown (no badges) and continues with the remaining chunks instead of abandoning them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cce0de5704 |
feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when posted, unlinked vouchers already summed to the bank row (PR #2300), but the MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate) called the RPC with no guard, so an agent could book a Bankgirot aggregate a second time. The detector existed once; the guard lived in one door. One shared decision helper, lib/invoices/already-explained-guard.ts, now sits on top of the existing detectors (no fork) and is called by the dashboard route, the MCP staging tools and the commit executors: - gnubok_match_batch_allocate refuses to stage, coded BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match / link_transaction_to_journal_entry call that resolves the row, and the exact force + expected_journal_entry_ids binding. - commitMatchBatchAllocate runs the same guard before the RPC and re-validates a staged force binding against the set detected at commit, so a stale approval cannot book a duplicate; 409 auto-rejects with the vouchers in result_data. - force + expected_journal_entry_ids on the tool mirror MatchBatchSchema; an honoured override stages with a compliance_warning and, after the booking succeeds, writes BankTransactionDuplicateDismissed to behandlingshistorik (dashboard route included; it only logged before). - gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force + expected_journal_entry_id; at commit it runs before the storno. - Registry: both duplicate codes gain retryable: false and a remediation. Catalog payload held under the 60K ceiling by trimming the two tools' own descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review) Review round on PR #2346 (CodeRabbit + compliance): - guardAlreadyExplained returned 'clear' when the detector threw even with force=true, so a forced 1:N override could book without re-validating expected_journal_entry_ids and left no behandlingshistorik record. It now returns a distinct 'unverifiable' outcome under force (mirrors guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool and the commit executor all refuse it with the new registry code BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression tests on every caller. - A detector failure without force still fails open at stage time, but no longer silently: the tools track onDetectError and stage a complianceNote, so preview_data.compliance_warning is set on both match_batch_allocate (GenericPreview renders it) and match_transaction_invoice (MatchTransactionInvoicePreview now renders data.compliance_warning through AttnLine). - expected_journal_entry_ids / expected_journal_entry_id are validated at the MCP boundary (array of 1 to 10 non-empty strings / non-empty string) and refused with VALIDATION_ERROR instead of being silently filtered. No schema description text added: catalog payload unchanged. - RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR 2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9cb1d105e3 |
fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)
* fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them Self-hosted deployments on an OpenAI-compatible provider (or with no AI configured) still showed every entry point into the tool-loop runtime behind /api/agent/invoke, which answers 503 there. The capability lived server-side only (getAiStatus().assistantAvailable); no UI could read it. Hand the flag to the client through CompanyContext (useAssistantAvailable, beside the paid-capability gate) and gate each entry point that opens AgentChat: the bookkeeping page's "Skapa med assistent" and "Med assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake and /chat/new?intent=. The floating trigger falls back to general help (the single-call console runs on any provider) instead of hiding, and AgentChat itself never fires an invoke without the runtime, so a resumed thread or a forgotten entry point shows a notice instead of a 503. The Hem checklist's "Anslut till Claude" step renders only where the assistant runs on Claude and the mcp-server extension is on. Provider-agnostic AI (ask console, categorization, extraction) and the server-side 503 are unchanged; on hosted the flag is true and nothing changes. Closes #2204 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(ai): use a placeholder that cannot match an Anthropic key shape The new direct-Anthropic status test assigned a string in the exact format of a live API key, which trips secret scanners on every run. The config only reads presence, so any non-empty string exercises the path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
272d19b287 |
fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side (#2299) (#2345)
* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side The mark-paid guard probed merchant_name for the FULL supplier name, so the row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never matched and the payment was booked twice (#2299). - counterpartyNeedle(): first distinctive token of the name (alnum, legal forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on merchant_name OR description in one .or() per currency sweep; the alnum shape is what makes the DSL interpolation safe. - findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer detector; both share the sweep and the scorer. The dashboard route's inline copy is deleted; the v1 supplier mark-paid door gets the guard it lacked. - New match_reason already_booked (row already carries a verifikat, booked straight from the bank side): ranked first, carries journal_entry_id, and the dialogs, MCP path and pending-operation commit word the remedy as a rattelse rather than "link it". - Customer side gets the same token prefilter and classification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard The customer detector now issues one .or() counterparty probe per currency sweep instead of two ILIKE queries, so every queued answer after the guard was consumed one step early: the aggregate-sweep [] became company_settings, the settings row hit the entry builder, and two tests saw 500 / the wrong voucher id. Each guard block now enqueues one probe plus the aggregate sweep; the 409 tests drop the second-probe entry that is no longer read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): one logic expression per duplicate-payment sweep, never two or= params The sweep chain carried two .or() calls (currency clause, then name probe). postgrest-js appends a query parameter per call, so the client sent or= twice, and whether PostgREST ANDs a repeated key was never proven in this repo; had it kept one, the currency predicate would be gone and foreign rows banded against a kronor figure. counterpartySweepLogic() now nests both groups under one and() inside a single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*, description.ilike.*x*)). The sweep issues exactly one .or() per currency. Proof at three levels: unit tests pin the helper's string; a fake-fetch test runs the real postgrest-js builder and asserts exactly one or= search param per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit (with an amount_sek that would pass every JS check) and right-currency+miss rows against a real PostgREST and asserts, for both detectors and both sweeps, that only the first comes back, from PostgREST's own response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): name storno as the already_booked remedy, never "makulera" A posted verifikat is never deleted; it is corrected by a storno entry (BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the MCP and pending-operation messages and both UI descriptions now say so: "vänd en av verifikationerna med storno och koppla underlaget till den som blir kvar" / "reverse one of the two vouchers with a storno entry and attach the underlag to the remaining one". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
82d25b7dee |
fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar (#2213) (#2344)
* fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar
The Betalningar row on the customer invoice page rendered "Inga
registrerade betalningar ännu" whenever invoice_payments had no row, even
under a header saying Betald / Betalning mottagen / Återstår 0 kr. A
migrated invoice that was paid in the previous system is exactly that
state: the provider migration writes status, paid_amount and paid_at from
the source and nothing else, while invoice_payments is written only by
Accounted's own settlement paths (all fail-closed). "Settled with zero
rows" therefore never means "no payment yet"; it means the payment was
recorded where this ledger never saw it.
lib/invoices/payment-history-gap.ts classifies that state from the data
(no provenance column): settled + zero rows + no posted invoice_paid /
invoice_cash_payment voucher keyed on the invoice renders one line,
"Betald {date}, före migreringen till Accounted" (partial and undated
variants); settled + zero rows + such vouchers (#2019 leftovers) lists
the vouchers in place of the rows, linked to the verifikat; a failed
lookup says the history could not be loaded instead of asserting either.
payment_status_empty is removed from both locales: no reachable state
renders it any more.
Closes #2213
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6
* fix(invoices): log a failed payment voucher lookup instead of returning null silently
fetchInvoicePaymentVouchers returned null on a DB error with no trace, so
a failure behind "Betalningshistoriken kunde inte hämtas" was invisible.
Warn with the invoice id and the error code/message (no invoice content),
and assert it in the test.
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>
|
||
|
|
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> |
||
|
|
162de2128a |
fix(import): show "created on import" as the mapping target for source accounts the chart lacks (#2342)
The guided Fortnox import self-mapped a source account that exists in neither the company chart nor BAS (4599) and then rendered its Malkonto select blank, because the dropdown only knew chart + BAS accounts. The row looked unmapped and unmappable while the import created the account correctly. A nameless account (referenced by #TRANS without #KONTO) was worse: the mapper refused the self-map, so it stayed unmapped with no self-target to pick. - account-mapper: the bas_range self-map no longer requires a #KONTO name; unmapped now means exactly "outside 1000-8999". isValidBASRange exported as the auto-create boundary. - AccountMappingStep (shared by both wizards): a target the list cannot name is an explicit "<nr> <name> (skapas vid importen)" option, a "nya konton skapas" badge/filter lists them, out-of-range accounts that block Continue are named, nameless sources say so. - sie-import: skippedVouchers.unmappedAccounts (per account, voucher count) via summarizeUnmappedSkips; warning names the accounts. - Migration result step: names created accounts and the accounts behind "med ej kopplade konton". Closes #2212 Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ea4da0eb07 |
fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)
Part of #2059 (Part 2, the hardening bug). The FK on invoice_items.article_id proves the article exists, not that it belongs to the writing company: FK validation ignores RLS, and the v1 routes run on the service-role client with no RLS at all. Only the two MCP commit executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and sales-order writers passed items[].article_id straight through the builder. Move the check to the one point every writer converges on: buildInvoiceWriteData collects the distinct article ids from product lines, runs one select scoped on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400, Swedish message via the structured-error registry) on any miss. The MCP executor checks stay as the tamper gate for staged rows. Tests: builder unit cases (miss refused with details, dedupe + happy path, no article ids means no query, DB error surfaces as dbError), cookie PATCH and v1 POST refusal cases, and the existing v1 persist + MCP update tests now answer the builder's scoped select. Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b71f2bf425 |
feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line
The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).
- create_expense_payout_batch(..., p_transaction_id): old signature dropped
so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma
- "Matcha mot utlägg" in the inbox row menu: pick the person and the
receipts a transfer covers when the exact-amount pairing missed it. The
picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
from Att göra, the attention resource, suggestions and the picker; a
payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
the dialog says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
238cbe13f9 |
feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules A yearly or quarterly recurring schedule had no way to say which month it bills in: the dialog exposed interval and day of month only, so a yearly schedule created in September always fired in September. The phase of a schedule is fully defined by its first run date, which the table already stores as next_run_date and the create API already accepted as start_date but nothing exposed. - Dialog: new date field (first invoice date on create, next invoice date on edit), prefilled with the next natural occurrence so the default is "no offset"; kept in step with day of month both ways; shows the following three run dates so the phase is visible. Sent as start_date on create and as next_run_date on edit only when the user actually re-phased. - API: create validates start_date (on the day_of_month grid, not in the past); update accepts next_run_date (on the grid for the effective day, strictly after today in Stockholm) and lets it win over the automatic recompute a day change or reactivation does. - Staged operations / MCP: start_date documented as the phase; update tool gains next_run_date. Commit executor rejects off-grid dates and rolls a date that went stale before approval forward on its own grid. - lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared by the dialog, the routes, the executors and the cron service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC * fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog Resolves the skeptic and CI findings on #2338 in one pass: - MCP staging tools now apply the same grid and past/future rules as the routes to start_date and next_run_date, so the preview a human approves is exactly what the commit executor writes (previously an off-grid date staged fine and failed at approval, and a past next_run_date was rolled to another date silently). - The dialog computes today and the default first invoice date in Europe/Stockholm instead of the browser's zone, matching the server; getStockholmDateHour moved to the client-safe module and is re-exported from the service. - gnubok_update_recurring_schedule description trimmed under the 280-char limit while keeping the clamping and Stockholm phrases the registration test requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0c854ac54f |
feat(settings): let a company name each verifikationsserie letter (#2336)
* feat(settings): let a company name each verifikationsserie letter
The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.
- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
letter is called: company name, then preset, then empty.
buildVoucherSeriesOptions replaces the three near-identical option
builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.
Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): keep imported series in the list and unsaved names through a refetch
Skeptic pass on the series-name editor refuted two things:
- The rewritten list filtered voucher_sequences to single letters, dropping
multi-character series (FT, LB, SKV, ...) that 54 production companies
carry over from Fortnox and Bokio imports; the old list showed them with
their highest number. Rows are now every used series plus the configured
and named letters; only single-letter series get a name input, since
those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
and the settings hook revalidates on window focus with a fresh object, so
unsaved typing was wiped after any earlier save on the page. The re-seed
is now keyed on the serialized content of the saved names.
Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): name the default-series options and enforce the label shape in the database
Review pass on #2336:
- CodeRabbit: the Standardserie selector under Bokföring still rendered
bare letters; it now shows the same name the other pickers do, through
voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
voucher_series_labels lived only in UpdateSettingsSchema. Migration
20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
non-blank strings of at most 40 characters), so a write that bypasses
/api/settings cannot store a map the pickers cannot handle. Applied to
staging with its schema_migrations row; verified against good, empty,
lowercase, blank, over-long, numeric and array inputs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* test(pg): cover the voucher_series_labels CHECK against real Postgres
The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
0e3c0af841 |
fix(sie-import): refuse a closed or locked target year up front and point at Öppna igen (#2334)
A SIE file whose #RAR falls inside an existing fiscal year answered
'match' in precheckFiscalPeriod without looking at is_closed or
locked_at, so the import ran into the atomic voucher RPC and surfaced
the DB trigger's own text ("Cannot write to locked/closed fiscal
period"). Observed 2026-09-04: an owner klarmarkerade an empty prior
year, could not import its single aktiekapital voucher, and never found
the "Öppna igen" button that undoes klarmarkera.
The precheck now returns a conflict verdict for a closed or locked
containing year, with the remedy per state: Öppna igen for a
klarmarkerad year, Lås upp for a locked one, and no false hope for a
year closed by a year-end run. The parse preview shows the same text
and disables the import; executeSIEImport's no-create branch gets the
same refusal.
The årsredovisning builder now warns when the comparison year exists
but holds no entries in Accounted: the column reads 0 kr, ÅRL 3 kap.
5 § requires the prior year's amounts, and the warning says where the
fix lives instead of printing zeros silently.
Claude-Session: https://claude.ai/code/session_015kJVo845t3ZMtFcCMhFEuk
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
b0b995c77c |
fix(tab-guard): name the company the other tab switched to (#2328)
The cross-tab guard dialog said "another tab switched the active company" without saying to which one, so the two exits read as "your company" versus "the new one". Resolve the observed company id against the memberships the shell already ships to the client (switcher list plus the foreign-host signpost list; no request at the moment the tab is told to stop) and say "en annan flik har bytt till Demo AB" and "Ladda om som Demo AB". Unknown ids keep the unnamed wording. Founder re-confirmed the blocking two-exit design (WL-09) today after a forensic pass on a real firing; this is copy only, no behaviour change. Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
473b1fd2eb |
fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)
* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials Every Björn Lundén connect in prod has failed with "Leverantören avvisade autentiseringen" (10 consents since June; only BL's own sandbox company ever received tokens). Live-verified against a real customer User-Key today: BL answers 403 "<service>:READ is out of allowed scope for service provider Arcim" on every read endpoint. The key is right and binds the company; the company has simply never activated our integration, and it cannot until BL moves the listing out of sandbox. The generic 403 mapping told the user to re-check what they pasted, which can never help. - BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError, matching the verbatim live 403 and 500 bodies. - submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind 'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our own client_credentials token refused) rethrows as a generic submit failure instead of blaming the pasted key. - New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix (activate under Integrationer in Lundify, else SIE) and where the GUID is. - Wizard copy for BL moved to i18n keys and reordered: activate first, then paste the key; the key only works once the integration is activated. - Tests: route mapping for both kinds, probe classification incl. the captured live bodies, registry entries pinned to 422. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY * fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up User-Key answered 500 with a Spring BeanCreationException for databaseConnector, not the null getCurrentUser() message captured earlier. The unknown-key verdict already keys on the status alone in submitProviderToken; keep only the 403 scope matcher, whose body IS stable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
41a5728ca7 |
fix(expenses): review follow-ups from #2317 (#2326)
- The Utlägg nav row is computed server-side, so the first booked claim now refreshes the App Router tree instead of staying hidden until a full reload. - The dialog's default date is the local calendar date; toISOString() is UTC and dated a receipt booked after midnight CEST to the previous day. - listExpensePayoutsDue pages through every registered claim with fetchAllRows instead of stopping at 500 rows: a person omitted or a total understated there is money the company owes someone. - The attention resource's payout instruction names the liability account per row (2893 / 2018 / 2820) instead of only 2893/2820. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
eb2ae1da17 |
fix(invoices): render statutory PDF notices in the document language (#2321)
* fix(invoices): render statutory PDF notices in the document language An English invoice PDF printed "Omsättning utanför EU, ML 10 kap." and "Godkänd för F-skatt" in Swedish, and the notice boxes below the totals (proforma, VAT notice, notes) each had their own colour, border and spacing, so the stack looked patchy. The export notice is stamped in Swedish on invoices.reverse_charge_text at create time and stored as a snapshot; the PDF printed it verbatim. The template now matches the stored text against the shared EXPORT_NOTICE_SV constant from vat-rules.ts and renders it from LABELS in the document language, so already-created invoices are fixed as well. Custom or unknown text is printed exactly as stored. The English footer reads "Approved for F-tax (Godkänd för F-skatt)": SFL 10 kap. 12 § requires the approval to be stated but prescribes no language, and Peppol SE-R-005 is satisfied by the UBL file, which is unchanged. All notices share one noticeBox style: same border, radius, padding and spacing in a neutral palette. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYtWUhYjfQyrEJodjipxW1 * docs(invoices): cite the skill for the F-tax wording and add docstrings Review pass on PR #2321: the Swedish compliance review flagged that the F-skatt comment asserted an SFL paragraph without a skill citation, so the comment and the DECISIONS.md line now rest on what the swedish-invoice-compliance skill states (no language requirement for invoice text in ML) and on the literal Swedish phrase staying on the PDF. CodeRabbit's docstring check wanted JSDoc on localizeVatNotice and the test helpers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYtWUhYjfQyrEJodjipxW1 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cbe5580886 |
feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)
An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
bff44e5757 |
fix(parties): one "från SCB" note per contact section (#2316)
* fix(parties): one "från SCB" note per contact section, not one under every field The founder read four tags in a row. Kontaktuppgifter now ends with one sentence naming the fields the register gave: "E-post, Telefon, adress och Momsnr från SCB." Nothing else changes; the fill rule and the equality test behind it are the same. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): the customer page's registry note tolerates the loading state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): the supplier page's registry note tolerates the loading state 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> |
||
|
|
971952fe19 |
fix(mail): request gmail.readonly alone, mailbox address via Gmail profile (Google verification) (#2301)
* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a "scope discrepancy": the authorization URL asked for `openid email` on top of gmail.readonly, while the Cloud Console declares gmail.readonly only, and the review string-matches the two. The extra scopes existed solely to learn the mailbox address from the id_token. Gmail's users.getProfile returns that address under gmail.readonly, so the consent request now carries exactly one scope and the callback reads the address from the profile. Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's reviewers log in with credentials we hand them and treat a second factor as an "authentication blocker"; app_metadata is service-role only, so this is an operator switch for demo accounts, never a user-reachable setting. Tests: scope pinned in google-oauth.test.ts, profile read in gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in mfa.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ * fix(auth): time-box the reviewer MFA exemption instead of a boolean flag Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt relied on someone remembering to clear it. The exemption is now app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies in the future, so a forgotten flag dies on its own. Anything malformed or non-string enforces MFA. Still service-role only, still meant for the one demo account Google's OAuth reviewers log in with. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0ad83b8d71 |
feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party Founder feedback on the first Företagsuppgifter (2026-09-05): the org number twice, the VAT number twice, the legal name repeating the heading, and Kontaktuppgifter showing dashes while the block above had the phone, e-mail and address from SCB. - After a fetch the register's contact details land on the supplier and customer rows that point at the party: an empty field, or one still carrying what the register said last time, takes the new value; a value a person typed stays. Shown as "från SCB" on the row (by equality with the registry fact, no source column). - Företagsuppgifter becomes one status line (legal form, active or not, registrations, a Bolagsverket warning when there is one), industry, seat with registration date, and size. Identity stays in the header (org number now formatted) and Kontaktuppgifter. The legal name shows only when it differs from the row's name. - lib/parties/registry-summary.ts reads the coded SCB facts once for the page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape. - v1: party_id on supplier and customer list rows and detail; ?expand=party on detail embeds identity, the register summary, what the ledger has seen and payment identities. MCP: party_id on gnubok_list_suppliers/customers rows and gnubok_get_party (by party, supplier or customer id). Read-only; the parties resource follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): regenerate the API skill for the party expansion; tighten the get_party description Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f33628f005 |
feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along The preview scored the file's accounts against the BAS reference and said "matchas mot din kontoplan", so a consultant with a 41-account seeded company read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap with a non-empty period was only refused after the mapping step. - Parse route adds preview.chart (accounts new to THIS company vs already present, with a sample) via planChartChanges, and preview.fiscalYear from precheckFiscalPeriod: the containment/overlap verdict extracted out of ensureFiscalPeriod, which now consumes it, so preview and import cannot drift. - Preview card renamed to Kontoplan with the counts and the fiscal-year verdict (match / create / conflict with the import's own refusal text). - Review step lists the chart among "Vad händer när du importerar?". - executeSIEImport reports accountsCreated from the account sync; the result grid gets a Konton skapade card. No import logic changed; no migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC * fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton" Skeptic pass on #2307 refuted the first cut twice: - "Skapas vid import" was shown for a #RAR the import then refuses under BFL 3 kap. (19 months, non-month-end finish, mid-month start after an earlier year). The shape rules move into precheckFiscalPeriod as a fourth verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a consumer of one verdict, same query order. - The Kontoplan card counted unmapped sources under "Läggs till" and kept listing them after the create button, while the result said 0 created. planChartChanges (now client-safe in lib/import/chart-plan.ts) counts mapped targets only; the create button moves those accounts from "Ej mappade" to "Finns redan" in place. - The review line claimed existing accounts keep their name unless you opt in; the switch defaults to on. Reworded to match. - Sample names follow the file for identity mappings, as the sync does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
04898c3178 |
feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)
Founder test on a real company (2026-09-05): a supplier created from "Webhallen Oktober · Dataskärmar till kontoret" kept that text as its name, and the SCB facts fetched for the party were nowhere on the supplier page. - Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name, org number, VAT number, country, then the SCB facts under one source line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one action. The registry helpers move out of the dossier into RegistryFacts so the three surfaces share them. - The enrich route makes the registry's legal name the displayed name of the party and of supplier and customer rows that still carry the party's old name; all-capitals names are set in title case (lib/parties/registry-name.ts). Names a person set stay. - legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps its vouchers, so a rebuild attaches the new key instead of offering the same company again. - GET /api/parties/[id] reports whether SCB is configured. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
397a3b9bca |
feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143. |