Commit Graph

820 Commits

Author SHA1 Message Date
Mattsson a83baede72 fix: block staging backend on production white-label hosts (#1903)
* fix: block staging backend on production brands

* fix: clarify production domain classification

* fix: alert on forbidden white-label backend
2026-08-25 19:55:53 +02:00
Mattsson 328ccda10d fix: protect public Auth flows from automated abuse (#1904)
* fix: add Turnstile to public auth flows

* test: isolate Turnstile auth tests
2026-08-25 19:14:06 +02:00
Mattsson c634430677 feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep

Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox
column, a bulkbar with select-all/clear, and a confirm dialog that books
every selected order with the standard order template (per-store payment-
method mapping, optionally one override account for the whole selection).

Server side, POST /api/webshop-orders/bulk-book books each order as its
OWN verifikat through the exact same flow as the single-order endpoint:
the guards, FX retry and race-free draft -> claim -> commit sequence are
extracted to lib/webshop-orders/book-order.ts and shared by both routes,
so nothing added to the single path can miss the bulk path. Partial
failure is reported per order and never aborts the batch.

Fixes #1880

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe

The account-group key template literal picked up a raw 0x00 byte during
generation (known escape-mangling hazard), making git treat the file as
binary. Same grouping semantics, plain '|' separator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings)

The sweep has no reviewing user, so everything the single dialog relies
on a human to catch is now refused per order or aborted:

- empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed
  sale classified as 12%, refunds reversing zero moms via 3004) is only
  allowed as the single dialog's editable prefill; bulk refuses with
  WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING
- invoice-mode payment methods: booking would foreclose Skapa faktura
  and post a wrong clearing leg; refused with
  WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not
  bypass the merchant's configured flow)
- 3740 residual above ore scale (gift-card gaps booked as
  'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE
- settings-fetch failure now aborts the sweep instead of silently
  rebooking every order to 1686 against the confirmed dialog
- maxDuration 300 so a platform kill cannot strand an order between
  claim and commit
- per-order guard details (e.g. journal_entry_id) survive into the
  failure envelope

The dialog mirrors the skip rules up front (named order numbers, not an
anonymous count) so the confirmation describes exactly what will book.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep

A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown
gate with zero residual, but the rate-to-account maps would fall back to
the 25% accounts and book foreign VAT as Swedish utgaende moms 2611
(skeptic finding). The sweep now refuses such orders per order with
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending
rates); the dialog mirrors the rule and names the skipped orders. Only
the single dialog may show that prefill, as an editable guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:34:34 +02:00
Mattsson 5fc0be9ed7 feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking

Booked webshop orders only carried the VAT split; the verifikat showed no
product lines, customer or payment method although the sync already stores
all of it in webshop_orders.line_items (#1881).

- lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf
  template (order lines, customer, payment method, per-rate VAT summary,
  SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and
  archives the PDF on the committed verifikat through uploadDocument
  (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf.
  Never throws: the booking is immutable by then.
- book route: archive after commitEntry; response gains underlag_archived.
  FX-retry now also syncs the in-memory row so the underlag shows the
  resolved SEK facts.
- webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration
  20260825140000) to the verifikat_without_documents needs-doc list, so a
  failed attach or a historical booking surfaces on the saknar-underlag
  worklist. transactions_without_documents is deliberately unchanged.
- tests: underlag model/render/archive unit tests, book-route archive and
  failure-isolation cases, pg test extended (per-source-type probe now
  covers webshop_order; explicit flagged/silenced pair).

Fixes #1881

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): move webshop needs-doc migration after main's 20260825150000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(webshop): add manually_booked fields to the underlag order fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop): skeptic findings on the orderunderlag (#1881)

Two refutations from the skeptic pass on PR #1899, both fixed:

1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which
   Helvetica/WinAnsi PDF fonts drop silently, so refund and discount
   amounts on the archived underlag rendered as POSITIVE. formatAmount now
   replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency),
   is exported, and is pinned by a regression test.

2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed
   webshop_order, so flagged rows rendered without the "Underlag saknas"
   chip, waiver toggle, or batch-exempt selection, and the weekly
   missing-underlag push cron disagreed with the badge. The constant now
   lives in dependency-free lib/worklist/types.ts (client-safe), is
   re-exported from categories.ts, and both JournalEntryList.tsx and
   push-notifications/notification-scheduler.ts consume it instead of
   their own copies.

Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic
observation: the dialog's lines are user-editable, so the underlag must
state the order's conversion, not claim a booking fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:15:33 +02:00
Mattsson c6f2bebab9 fix(sie): selectable IB voucher series, smarter IB toggle on re-import, orphan-IB guard (#1896)
* fix(sie): selectable IB voucher series that never collides with the file's numbering

The Ingående balanser voucher was hardcoded to series A and created before
the file's vouchers, so it consumed the A series' next number and shifted
every imported A voucher one number higher than in the source system
(issue #1882).

- IB voucher series is now selectable in the import wizard; the default is
  the first of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER records
  (M matches the existing migration-adjustment series).
- Plumbed end to end: wizard -> /api/import/sie/execute -> executeSIEImport,
  v1 REST options.openingBalanceSeries, MCP gnubok_import_sie
  opening_balance_series -> commitImportSie.
- The wizard's 'Importera ingående balanser' toggle now defaults OFF when a
  posted IB voucher already exists inside the file's fiscal year, with a
  hint saying why.
- Orphan-IB guard in executeSIEImport: replace_sie_import deletes only
  source_type='import' entries and clears the period's OB pointer, so a
  prior import's IB voucher survived every replace cycle and each re-import
  created another one (field report: five accumulated). The import now
  skips IB creation with a warning when a posted opening_balance entry
  already exists in the period.
- MCP import_opening_balances default (false) vs web (true) documented as
  deliberate in the tool schema and DECISIONS.md.

Fixes #1882

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): harden IB series fix after skeptic review (relink orphan, exclude fallback series, type-check option)

Skeptic findings on PR #1896, all four blocking items:

- Orphan-IB guard now relinks a single surviving opening-balance voucher
  as the period's OB entry (permitted by the immutability trigger while
  the pointer is NULL): without it, reports showed IB 0, year-end's
  duplicate-IB blocker never armed, and the manual IB flow could
  double-book. It also diffs the survivor's lines against the file's IB
  and calls out stale amounts in the warning instead of keeping them
  silently; reverseEntry clears the pointer again for the
  storno-then-reimport path.
- Series-less #VER records resolve to the transaction fallback series at
  import time, so the IB default picker now treats that series as used by
  the file (the same #1882 shift pattern through the fallback). The
  wizard recomputes its IB default with the effective transaction series
  once loaded.
- openingBalanceSeries is type-checked on the web execute route, the MCP
  stage, and the staged-operation commit: a non-string falls back to the
  default instead of crashing mid-import after side effects.
- The wizard's IB series select flags series used by the file and shows
  an attention line when the chosen series collides; the engine warns
  when an explicitly chosen series collides with the file's series (the
  choice is honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): uppercase caller-chosen IB series before persisting

Swedish accounting review on PR #1896: a lowercase series from v1 or
MCP was persisted as-is, booking a case-distinct parallel series next
to its uppercase sibling (BFL 5 kap requires one systematic series)
and slipping past the file-collision warning. Normalize centrally in
executeSIEImport, the single funnel for web, v1, and MCP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:14:59 +02:00
Mattsson 79013cf092 feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883)

Two deliverables from the community report where a bad SIE test import
left no way out short of deleting the company:

A) Discoverability: the voucher list shows one attn line linking to
   /import?history=sie whenever the page contains import-sourced
   vouchers, and /import?history=sie deep-links straight into the
   fold-open SIE import history where per-import Angra already lives.

B) Reset of an UNLOCKED fiscal year regardless of how the entries
   arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape
   hatch as undo_sie_import; no enforcement trigger touched) behind
   GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed
   type-the-year-name confirmation dialog on the fiscal years settings
   list. Refuses on: locked/closed year, company lock date over any part
   of the year, executed year-end, arsredovisning state, later year
   depending on this year's UB, VAT-declared evidence (vat_settlement
   verifikat, SKV lock/submit audit rows, extension workflow keys, fail
   closed) and AGI-declared months. Entries referenced by RESTRICT/NO
   ACTION FKs abort the whole reset (all-or-nothing). Documents are
   detached, never deleted (BFL 7 kap); every delete is audit-logged
   plus one behandlingshistorik summary row.

Fixes #1883

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883)

Blocking skeptic findings on PR #1897, one consolidated pass:

- New snapshot blocker cross_year_reference: an entry outside the year whose
  correction_of_id / reverses_id / reversed_by_id points into the year made
  the delete crash with an uncaught P0001 (immutability trigger refusing the
  ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and
  silently severed draft chains. 12 such chains exist in prod today.
- New snapshot blocker rot_rut_state: a begaran om utbetalning that reached
  Skatteverket (submitted/paid/partially_paid/rejected) was silently
  unlinked via SET NULL, erasing the bokforing behind a filed and possibly
  decided myndighetsarende.
- Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit
  rows carry no company_id and header rows no amounts, so a reset destroyed
  konton/belopp with no company-readable trace. The RPC now archives the
  full content of every verifikat in company-scoped RESET_SNAPSHOT audit
  rows before deleting (action added to audit_log_action_check, NOT VALID),
  and behandlingshistorik renders them.
- Dimension registry lockstep on reset (mirrors undo_sie_import): flipped
  imports can never be undone again, so their dimensions/values would have
  been orphaned forever.
- EXCEPTION WHEN raise_exception now returns a typed
  FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500;
  gnubok.allow_delete is cleared before leaving the guarded block.
- Voucher-list attn line fires only for source_type 'import':
  opening_balance is also written by year-end closing and the manual IB
  flows, which mislabelled every year-2+ company as SIE-imported.
- /import?history=sie now scrolls the SIE history into view.
- Reset dialog copy (sv+en) discloses that linked invoices, payments and
  bank transactions become unbooked; new blocker strings in both locales.
- pg fixture fix: document_attachments seeded without company_id (23502);
  new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:36:18 +02:00
Mattsson 77cacdcf34 feat(mcp): personal_number on gnubok_update_customer (#1876) (#1890)
gnubok_create_customer takes a personnummer (encrypted before approval)
but gnubok_update_customer did not, so an existing customer whose
personnummer sat in the org-number field could not be corrected via MCP.
The REST PATCH already supports it; this closes the MCP/pending-operations
gap across its three layers:

- tool inputSchema: personal_number (string or null) on the strict
  whitelist. The tool validates the plaintext before any DB read and
  mirrors the REST PATCH semantics: masked echo (********-1234 or
  ********-????) = leave unchanged, explicit null = clear, absent =
  untouched. Setting is refused unless the row ends up as an individual
  (GDPR art. 5.1 c), including via a simultaneous type change.
- CustomerChangesSchema: personal_number_encrypted (nullable, ciphertext
  shape per customers_personal_number_check 20260726110000). The
  plaintext key stays forbidden by .strict() and staging-pii-guard.
- update executor: maps the staged ciphertext onto customers
  .personal_number (set/clear/leave), re-checks the individual-only rule
  against a tampered row, and returns only personal_number_masked.

PII handling: the personnummer is encrypted at staging time
(AES-256-GCM, same path as create); pending_operations params carry only
the ciphertext and the approval preview only the masked form. Idempotency
hashing switches to the masked preview for personnummer-bearing updates
(random-IV ciphertext would break retries); other updates keep their
previous hash identity.

catalogVisibility stays 'search': tools/list is at its 59.95K token
ceiling with zero headroom (see DECISIONS.md).

Fixes #1876

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:35:02 +02:00
Jakob Wennberg d80103a2f5 fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed (#1888)
* fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed

Two reports from the same salary run (Fabian, Specific AI Sweden AB).

1. The payment file carried an OCR Skatteverket does not accept.
   generateSkattekontoOcr built the reference from the TEN-digit org number
   plus a Luhn check digit (11 digits). Skatteverket's reference is the
   TWELVE-digit identity plus a check digit: an organisationsnummer carries
   the "16" prefix, a personnummer its century. For 559547-0021 we emitted
   55954700211 where Skatteverket prints 1655954700217.

   The twelve-digit form is the same "redovisare" identity the AGI and moms
   APIs take, so it now goes through the shared toRedovisare12 converter
   instead of a second local rule: the payment file and the declaration it
   pays must not disagree about who the taxpayer is. That needs the entity
   type, which the route now reads alongside org_number.

   The route also prefers saldo.ocrNummer from the cached skattekonto
   snapshot over the derived value. It is Skatteverket's own answer for the
   account we actually sync, it covers identities the converter has no rule
   for (samordningsnummer, GD-nummer), and it covers the companies whose
   companies.org_number has drifted from company_settings.org_number.

2. AGI status stayed on "väntar på BankID-signatur i Mina Sidor" after the
   user had signed.
   Reading the kvittens needs a live Skatteverket session, and the personal
   token lives ~65 minutes, so by the time anyone signs in Mina Sidor the
   2-hourly kvittens cron finds a dead token and skips quietly. The panel
   kept asserting a state it could no longer observe.

   It now says so instead, and the reconnect action already on the panel is
   the fix: runPostConnectRefresh reconciles pending declarations on a fresh
   consent. sessionExpiredStatus also counts the needs_reconsent health flag,
   which a cron can set while the access token is still inside its hour;
   without it the panel reported a dead connection as healthy.

   Background reconciliation without a reconnect needs the läsombud grant,
   which is a registration decision and not part of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skatteverket): say why the entity_type collapse in the payment-file route is total

companies.entity_type is NOT NULL with CHECK IN ('enskild_firma',
'aktiebolag'), so the ternary cannot silently mis-tag an enskild firma as
a legal entity and give a personnummer the "16" prefix. Two review bots
read it as an unguarded default; write down the constraint that makes it
safe instead of leaving the next reader to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(decisions): record why the cached skattekonto OCR needs no freshness gate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:25:00 +02:00
Mattsson cbfb2201ff fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)
* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884)

Four silent drop paths made a paid RUT invoice invisible in the begaran
dialog (neither eligible nor blocked), and the empty list hid the year
picker so the dialog looked dead:

1. deduction lines without a header deduction_total: a second line-based
   candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING
   (also at file generation: the 1513 receivable was never booked).
2. partially_paid with the customer share settled: remaining_amount = 0
   (total - paid_amount - deduction_total, migration 20260817191708) now
   counts as paid in evaluateInvoiceForFile; a genuine partial blocks as
   NOT_PAID with the outstanding amount.
3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message
   points at the other type, and the dialog's empty state adds a
   switch-type hint.
4. invoices held by a generated/submitted begaran block as
   ALREADY_REQUESTED naming the request; decided requests stay omitted
   (finished business, visible in the history list).

The dialog keeps the year picker rendered when the list is empty (current
year as inert fallback) and opens the blocked list by default when nothing
is eligible.

Fixes #1884

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884)

Two skeptic refutations against the frozen PR head:

1. Regression: the wrong-type branch ran before the active-request lookup,
   so invoices of the OTHER type whose begaran was already decided
   resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked
   list, and the empty-state hint pointed at a tab where they never appear.
   The decided-request skip now runs first, on every tab.

2. Correctness: the paid gate and the NOT_PAID message trusted
   remaining_amount, but payment-sync's storno path recomputes it WITHOUT
   subtracting deduction_total, so the stored column can carry Skatteverkets
   1513 share and the dialog could assert a wrong customer-outstanding
   figure. The gate now derives the customer share as
   total - paid_amount - deduction_total (the buildInvoiceWriteData /
   migration 20260817191708 formula) from fields every settlement path
   maintains.

Tests pin both: decided+wrong-type omitted from both lists, corrupted
remaining still classified and reported from the derived share.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884)

The skeptic-hardening commit moved the paid gate off remaining_amount to
the derived customer share (total - paid_amount - deduction_total); the
comment still named remaining_amount as the signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): explicit decided-status set + correction-path wording (#1884)

Swedish accounting review findings on the candidate list:

1. The decided-begaran skip inferred 'decided' by exclusion (anything not
   generated/submitted), so a future request status would make an invoice
   vanish from both lists, exactly the silent drop the module forbids.
   DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other
   status held by a request lands in blocked as ALREADY_REQUESTED with a
   generic message. Test pins it.

2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which
   could read as an invitation to edit a booked invoice directly. The
   invoice edit route already refuses sent/paid/booked invoices, and the
   message now names the sanctioned path: drafts edit directly, sent or
   paid invoices are corrected via credit note + new invoice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:24:01 +02:00
Mattsson 0bb482bf6e feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader

Proposal views (AI suggestion, static template, counterparty template with
or without a line pattern) previously offered only accept-or-start-over: the
verifikation preview was pure rendering and the only line-editable path was
library templates. This adds an "Andra rader" affordance to the proposal
view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK
amounts, VAT legs, exactly what the preview shows) into
TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing
the same initialLines mechanism library templates already use.

- lib/bookkeeping/proposal-lines.ts: line computation extracted from
  JournalEntryPreview into computeProposalLines() (single source for preview
  and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping
  to the JournalEntryForm prefill shape. The settlement leg is flagged so
  the booking dialog swaps in the transaction's resolved cash account and
  stamps currency metadata, mirroring buildInitialLinesFromTemplate.
- JournalEntryPreview now renders computeProposalLines() output unchanged.
- TransactionBookingDialog accepts proposalLines (takes precedence over
  preselectedTemplate); the booking still goes through JournalEntryForm's
  normal manual validation and the engine, no validation bypassed.
- Ore rounding funnels through roundOre(); guard baseline ratcheted down.
- New strings in messages/sv.json and messages/en.json (tx_quick_review).
- Unit tests for all three proposal branches incl. VAT legs, reverse
  charge, multi-line patterns, 3740 rounding diff and FX metadata.

Fixes #1878

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings)

Three skeptics refuted the first cut of #1878: the extracted preview math
was a lossy approximation of the engine, and making it bookable made every
loss a real booking defect. This commit closes each refuted scenario by
mirroring the exact engine path per proposal branch:

- Balance: VAT is single-rounded and the net leg is gross minus that VAT
  (transaction-entries.ts semantics). Independently rounded net+VAT went
  off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94),
  prefillling an unbookable verifikat.
- 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via
  resolveExplicitVat before computing lines, so an explicit no-VAT choice
  prefills no VAT line instead of re-deriving the 25% category default
  into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments).
- Ore parity: engineRound (plain Math.round(x*100)/100, matching the
  engine) replaces roundOre where the engine is naive; roundOre kept only
  where the engine uses it (category VAT leg). No more 1-ore drift between
  preview, prefill and the booked verifikat (8.62 RC, 34.30@12%).
- Legacy counterparty pairs: new counterpartyLegacy mode mirrors the
  legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair
  (previously dropped: an RC expense would have booked without fiktiv
  moms, understating rutor 30/48), VAT on expenses only, income gross, and
  sign-mismatched matches mirrored like buildLegacyMismatchResult.
- Pattern mirror: sign-mismatched line patterns flip learned sides like
  buildMultiLineMappingResult; ratio allocation filters business/tax types.
- Entity accounts: static template accounts resolve debit/credit_account_ab
  for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer
  previews or books EF-only accounts like 2013.
- Settlement swap: only a literal-1930 settlement leg is swapped to the
  resolved cash account (applySettlementAccount parity); learned non-1930
  money legs (1510/2440/2890/19xx) stay authoritative.
- FX: QuickReviewDialog hands its enriched transaction row to the booking
  dialog so the settlement leg's exchange_rate metadata matches the rate
  the SEK amounts were computed with.

34 unit tests incl. every skeptic counterexample; guard baseline ratcheted
to 622 (below main's 626).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation)

Two independent skeptics refuted the pattern branch: the engine books the
money leg on the counterparty template's learned legacy account (credit
for an expense, debit for an income, mirror-swapped, falling back to
1930), while the preview/prefill defaulted to 1930. A SIE-learned
pattern settling on 2440 showed kredit 1930 in the preview but booked
kredit 2440 on confirm. QuickReviewDialog now passes the learned pair
raw (no entity resolution, engine parity) and computeProposalLines
selects the settlement account exactly like buildTransactionEntryLines;
the literal-1930 swap to the resolved cash account is unchanged.

CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740
rounding line keeps the engine's business-side placement for both diff
signs (parity contract; an unbalanced set is rejected at commit), and
the naiveOreRound baseline stays at 622 (engineRound is a documented
parity exception).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:58 +02:00
Mattsson 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): resolve PR review findings in one pass

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:56 +02:00
Mattsson cd46d936f3 fix(bokforing): account search results in the booking dialog search field (#1877) (#1889)
* fix(bokforing): account search results in the booking dialog search field (#1877)

The search field in the "Bokfor transaktion" dialog only matched template
metadata, so typing an account number or name (e.g. active 5460
Forbrukningsmaterial) gave zero hits and no path to booking; the real
account search was only reachable via the discreet "Bokfor manuellt" link.

- TemplatePicker now also searches the company's active chart of accounts
  (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton"
  result group; picking one routes into the same manual booking flow as
  "Bokfor manuellt" with the account prefilled on the counter line.
- New buildActiveAccountIndex helper indexes chart rows active-only, so
  deactivated accounts never surface as bookable results.
- searchTemplates additionally prefix-matches all-digit tokens against a
  template's business account (debit for expense, credit for income, both
  legs for transfers, AB variants included); the settlement leg is
  deliberately excluded so "1930" does not light up every template.
- Library template search prefix-matches line accounts on all-digit queries.
- Empty search results show a hint pointing at "Bokfor manuellt" for
  accounts outside the active chart; placeholder now says "Sok mall eller
  konto...". New strings in both messages/sv.json and messages/en.json.

Fixes #1877

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokforing): match only business lines when digit-searching library templates (#1877)

CodeRabbit review on PR #1889: the all-digit library-template match also hit
settlement and VAT lines, so searching "1930" lit up every user template with
a bank settlement leg. Restrict the predicate to business lines, mirroring
the static catalog's settlement-leg exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 13:30:03 +02:00
Jakob Wennberg 31e0cd6e05 feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:41:02 +02:00
Mattsson 1fa34aa7ca feat(skatteverket): repair notification recipients + make the agent the SKV notification surface (#1887)
* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface

The company_members -> profiles!inner(email) PostgREST embed has no FK to
traverse (company_members.user_id references auth.users), so it 400'd and
silently killed all four notification emails since they shipped. Recipient
lookup is now a shared two-step helper (lib/notifications/member-email):
kvittens confirmations, skattekonto drift alerts (tax-contact routing
preserved via the plural variant) and backup alerts deliver again. The
connection-expired email is deleted instead of fixed: with SKV's 65-minute
personal sessions it was one mail per connect (see DECISIONS.md); the event
and needs_reconsent flagging stay.

For MCP-first users the agent is the notification surface, so:
- SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is
  normal (~1h by SKV design), only a person can reconnect with BankID, do
  not retry until they confirm. Inline strings (declaration-status, read
  routes, v1 pitfalls, accounted-api skill) aligned.
- gnubok_get_agent_briefing gains an optional skatteverket_connection block
  (status/source/connected_at + directive message on needs_reconsent),
  emitted only when a connection or verified system grant exists, so agents
  warn the user at session start instead of failing mid-task. Payload bench
  ceiling bumped 59.95K -> 60.15K for the outputSchema contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): drift email resolves recipients via service client; review fixes

The skeptic pass refuted the drift-email repair: skattekonto.drift_detected
is emitted only by the nightly cron, and the extension registry builds each
event handler a fresh ctx from the anonymous cookie client (or none at all
on cookieless requests), so RLS returned zero company_members rows and the
two-step lookup still resolved no recipient. The handler now builds its own
service-role client, the same documented pattern as the retired
connection-expired handler; drift tests exercise the handler without ctx,
matching the cron reality.

CodeRabbit findings: resolveMemberEmails pages both queries through
fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000
rows); the v1 vat-declarations pitfall and regenerated accounted-api docs
now name both auth paths (member BankID connection or verified ombud
grant); the briefing's system-before-user priority carries a cross-reference
to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc
states the service-role-client requirement (profiles RLS is own-row-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:09:20 +02:00
Jakob Wennberg f929b4b1d2 feat(mcp): lazy authentication so a client can connect before an account exists (#1814 PR 2) (#1892)
* feat(mcp): lazy authentication so a client can connect before an account exists

Second PR of agent-first onboarding (#1814). A client with no token may
now initialize, list the default catalog and call the three documentation
tools (search_tools, list_skills, load_skill). Every other request keeps
the transport-level 401 + WWW-Authenticate, which is what Claude, Claude
Code and Codex turn into their Connect prompt; with #1855 the account is
created inside that prompt, so the first protected tool call is the whole
signup trigger.

- The JSON-RPC body is parsed before auth so the method and tool name can
  decide whether a token is required. A tokenless unparseable body keeps
  the old 401 answer.
- Anonymous callers get an 'anonymous' actor, an empty scope set, a
  not-connected variant of the initialize instructions, and the full
  default catalog from tools/list (the agent has to be able to name a
  protected tool to trigger the challenge).
- Anonymous traffic is rate-limited per truncated IP via checkRateLimit;
  truncateIp moves to lib/api/ip.ts so the MCP server can use it without
  importing the v1 wrapper (which pulls lib/init and would cycle).
- gnubok_list_skills is now company-independent and skips its two context
  lookups when there is no company (anonymous or not yet onboarded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): gnubok_list_skills keeps its company_id argument as an optional-company tool

Making list_skills company-independent (so anonymous callers can run it)
silently dropped its company_id argument: a multi-company user asking
for another company's skill list got the key default instead. Optional-
company tools now advertise company_id and resolve (membership-checked)
it when an authenticated caller names one; anonymous callers cannot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:38:29 +02:00
Jakob Wennberg a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00
Jakob Wennberg dc92fb5c0c fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op (#1852)
* fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op

Feedback seq 261545 (deepCFO): approving a bulk_book_transactions op over
MCP returned BULK_BOOK_UNAUTHORIZED, yet the op vanished from /pending
with nothing booked; the user believed it had been approved.

Two defects:

1. The bulk_book_transactions RPC gates on auth.uid(), which is NULL on
   the cookieless service client every MCP approval runs on, so EVERY
   API-key approval of a samlingsverifikat was refused. New migration
   20260824170000 adds p_user_id, honored only for service_role callers
   (same gate as match_batch_allocate 20260817150000 and undo_sie_import);
   the executor passes the approving user, who is now also the actor
   stamped on the verifikat. pg-real test covers member/spoof/no-JWT/
   grants like the precedent.

2. The dispatcher consumed the op on ANY executor error other than 404/
   409. An authorization refusal happens before any side-effect and says
   nothing about the op, so 401/403 now release the claim back to
   'pending'. The executor maps RPC codes through the structured-error
   registry so 403/404/409 are distinguishable from 400. Every
   CommitResult carries operation_status (pending | committed | rejected
   | failed_partial), exposed on gnubok_approve_pending_operation, so
   agents stop inferring consumption from status 'failed'.

Catalog token ceiling 59.95K -> 60K per the documented ratchet protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

* fix(pending-ops): revoke anon explicitly on the service-actor bulk_book signature

Default privileges grant EXECUTE on new functions to anon; the pg-real
grants test (mirroring match_batch_allocate) caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:32:12 +02:00
Jakob Wennberg e35714518f fix(year-end): never seed opening balances into a non-adjacent period (#1849)
Feedback seq 249297: run_year_end on Räkenskapsår 2024/2025 seeded the
closing balances into an existing 2026/2027 period and left no period
at all for 2025/2026. Root cause: SIE import wires previous_period_id
to the NEAREST later period regardless of gap (the company had an
onboarding-seeded 2026/2027 when 2024/2025 was imported), and
findNextPeriod trusted the chain unchecked.

- findNextPeriod: a chained period is only the next period when it
  starts the day after the current one; otherwise log and fall through
  to the date lookup so year-end creates the contiguous period.
- createNextPeriod: relink a successor that was chained across the gap
  onto the newly created period, healing the chain.
- SIE import: wire predecessor and successor links only when
  date-adjacent. A gap stays unlinked until the missing year exists.

Prod scan 2026-08-24: 40 non-adjacent links across 39 companies (mostly
an old historical year chained to an onboarding-seeded current year).
The read-side guard neutralizes all of them for year-end; the data
repair is a separate, founder-approved step.

The reporting company (23dc3c97) self-repaired the same evening via the
fiscal-periods gap-fill route; the stray IB entry is reversed.


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:25:58 +02:00
Jakob Wennberg 3dce94d39e fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut, bilagor reminder (#1875)
* fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll

A year closed in a previous system (Klarmarkera perioden) has no closing
verifikat in these books by definition, so the statutory pre-closing guard
in the trial balance has nothing to strip; it now lets that case through
instead of failing the whole Årsredovisning page with a 500.

Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for
the bilagor inside the bokslut module, not under Rapporter), and the
wizard's Kontroll step says how many balance accounts are still unsigned
per balansdagen with a link to the pärm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(nav): admit the bokslutsbilagor label key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint

Twenty-odd balance accounts pushed the bank rows out of view on a migrated
company. The group opens when a manual account is selected and otherwise
remembers the last choice per browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:08:46 +02:00
Jakob Wennberg 9ce1ebc65f feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:35:02 +02:00
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:34:05 +02:00
Jakob Wennberg ebbdf96b74 feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1)

Every class 1-2 account the bank and skattekonto adapters do not own now
appears on the Avstämning page under "Övriga balanskonton" with IB, movement
and UB through the balansdag, a system specification where one exists
(1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld)
and, for every other account, the balance the signer states from their
underlag at sign-off. Same three doors as before: dashboard routes, v1 API
and the MCP tools take manual:<BAS> keys and an external_balance.

The ledger side is computed per fiscal period via generateTrialBalance,
never as an all-history sum: year-end re-books every balance account in an
opening_balance verifikat, so an all-history sum counts a closed year twice.

A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever
the system already has an outside truth, so it can never hide a difference.

No migration: account_reconciliations already accepts manual:NNNN keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* chore(api-skill): regenerate banking reference for the sign-off external_balance field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:23:14 +02:00
Jakob Wennberg 1e6e19afe9 feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872)
The residual door existed for the page and the v1 API (#1862) but not for
agents: an MCP client that found a 10 kr bank fee between a selection and its
verifikat had to hand the last step back to the user. gnubok_reconcile_residual
dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap /
direction / skattekonto refusals surface immediately), stages a
reconciliation_residual operation with the would-book verifikat as the
preview, and commitReconciliationResidual links and books on approval.

Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by
storno + unmatch); scope transactions:write like the v1 route. The op type
is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list
verified against the live prod constraint 2026-08-25), and the tool joins
the reconcile_month / close_period loadouts and the reconcile-month skill.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:43 +02:00
Jakob Wennberg 9ebb2e518f feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)
The old Bankavstämning report page was the only place a user could still tag
a bank row as ingående balans or move it to another bank account, so the new
/reconciliation page kept linking out to it and the reconciliation lived in
two places. Both row tools now live on the account overview (hover-revealed,
same endpoints), the ?autorun=1 deep link from the transactions inbox runs the
matcher on the new page, and the report slug redirects: old links, ⌘K, the
bokslut readiness wizard and the ignore toast all land on /reconciliation.

BankReconciliationView and its FocusedReport branches (own range preset,
help popover, autoRun plumbing) are removed; the source-grepping parity test
for its quick-book path goes with it.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:17 +02:00
Jakob Wennberg d88df74b85 feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:30:54 +02:00
Jakob Wennberg 3edbf0a2e3 fix(agent): chat console keeps its thread across turns and reloads (#1859)
Three user-reported failures in the assistant panel, one root cause each:

1. "The chat asks what I'm referring to" when continuing a thread. The
   single-call console (general.help, AskConsole -> /api/agent/ask) was
   stateless since the 08-20 model-agnostic cutover: conversationId was only
   the tool actor id, so every turn was answered blind, reload or not.
   The provider-agnostic GenerateTextRequest gains an optional `history`
   (real message turns before the prompt, in both the Anthropic-family and
   the OpenAI-compatible adapter; absent/empty leaves the request
   byte-identical to the single-turn call). The route loads the thread's
   earlier turns server-side (loadChatHistory: text only, hidden and tool
   rows dropped, alternation repaired, newest 16 rows / 10k chars) before
   writing the new question, and hands them to the model.

2. A full page reload (the deploy prompt's "Ladda om") closed the docked
   panel and dropped the thread from view. The panel now remembers its open
   thread per tab in sessionStorage (lib/agent-panel/session-restore) and
   the provider reopens it on mount; the sheet loads it exactly like a pick
   from "Tidigare konversationer". Close and "Ny konversation" forget it; a
   thread that no longer opens is dropped instead of retried on every reload.

3. "Can't type any more" once the update banner shows. DeployReloadPrompt's
   full-width wrapper sits at z-[60] after the panel in DOM order and
   swallowed clicks on the panel's composer; only the card takes input now.


Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:37:30 +02:00
Mattsson f75ea2384d feat(calendar): enable calendar sync for Viktiga datum (#1853)
Turns on the calendar extension (built Feb 2026, stripped in the 2026-03-02 production readiness deploy, never re-enabled): ICS feed settings, calendar workspace, subscribe button on the Viktiga datum page.

Hardening before first real use: feed serve route now requires the creator to still be a company member (offboarding stops the feed); stable pagination (due_date + id, dedupe) on feed queries; fetches inside the logged try block; invoice events limited to sent/paid/partially_paid/overdue; event UIDs rebranded to accounted.se while zero feeds exist; APP_URL fallback fails closed in production; mobile stacking for the deadlines header; settings note that Google Calendar needs default notifications on subscribed calendars; calendar workspace aligned with the design system.

Skeptic reviewed (3 refutations, all fixed) plus one compliance swarm finding (fixed). No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:32:44 +02:00
Jakob Wennberg 4fb449a474 fix(reconciliation): bank tile says 'Rörelse på banken' with the in/ut/antal split (#1858)
'Banktransaktioner i perioden (netto)' made a bookkeeper's abstraction do
the explaining; Jakob read it as gross activity. The tile now reads
'Rörelse på banken i perioden' and its sub-line shows the gross split
behind the net (in · ut · antal transaktioner), so 'netto' needs no
label. The bank status carries bank_transaction_inflow / _outflow /
_count for it; the bridge line uses the same wording.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:24:26 +02:00
Jakob Wennberg 34ad1b1936 feat(reconciliation): manual N:1 matching: two-pane worksheet + group links (#1851)
'Matcha manuellt' as designed: outside rows on the left (multi-select),
verifikat without an outside row on the right (single-select), the
selection's arithmetic in the footer, and one Koppla that is enabled only
when the difference is 0. Mode lives in the URL (?mode=match).

Engine: a pair is now one OR MANY outside rows against one verifikat.
Bank groups link per transaction (manualLink allows N:1 by design, so
partial success is reported per row). Skattekonto groups go through the
new linkSkattekontoRows: the verifikat's 1630 side must settle the sum,
one guarded UPDATE links the whole group, and a partial hit is rolled
back as LINK_RACE. 1:M stays UNSUPPORTED_PAIR_SHAPE until the residual
link table (6c). v1 pitfalls + API skill regenerated.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:24:07 +02:00
Jakob Wennberg 0ed26c2eca fix(mcp,bookkeeping): FX-safe mapping-rule VAT + honest nullable output schemas (#1846)
Follow-up sweep to #1842 for sibling bugs of both classes.

FX denomination (same defect as categorize's vat_amount):
- mapping-engine buildResult computed input VAT, reverse-charge fiktiv
  moms AND the rutor 20-24 basbelopp pair from the transaction-currency
  amount while journal lines are SEK. Now resolved through the same
  lenient SEK ladder buildTransactionEntryLines uses for the gross.
  Reachable at the ledger only via env-gated auto-book (dev/test) and
  the mapping-rules evaluate API, but one flag from production.
- categorize-core's header doc claimed transaction.amount already IS
  SEK; rewritten, it was license for exactly this bug class.

Output-schema nullability (same defect as matched_supplier_id):
- get_payslip.calculation_breakdown: returned as explicit null before
  calculation (the schema description even said so) but typed object.
- get_document_content.mime_type/size_bytes: verbatim nullable columns.
- export_sie: company_name nullable, and org_number was returned but
  never declared under additionalProperties:false, so strict clients
  failed EVERY successful export.
- currency (two transaction listings) and voucher_series (verifikat
  listing): nullable columns with defaults, loosened defensively.
- New declaration-pinning test in output-schema.test.ts.

Known and deliberately not fixed here: skatteverket bank-counterpart
hint compares a foreign amount to SEK (display-only, missed hint);
own-account-detector cross-currency magnitude tie-break (heuristic);
invoice-inbox still withholds rule proposals on foreign rows (guard
can be lifted separately now that the defect it guards is fixed).


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:14:21 +02:00
Mattsson 0676f5a564 feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side (#1840)
* feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side

The dashboard "Verifikat utan underlag" card (and the push-notification
link) pointed at /bookkeeping?missingUnderlag=true, but nothing read the
param: the user landed on the plain unfiltered ledger. The existing
"Visa saknade underlag" toggle also only filtered the already-fetched
page, so it could not represent the badge count across pages.

- lib/bookkeeping/missing-underlag.ts: shared resolver of "posted
  verifikat lacking underlag" (document-requiring source types, no
  current-version document, no anchored supplier-invoice reference per
  BFL 5 kap 7 §, no exemption), extracted from the bulk "Inget underlag
  krävs" route so list, bulk remedy and dashboard badge share one
  predicate.
- GET /api/bookkeeping/journal-entries?missing_underlag=true: resolves
  the full missing set server-side, applies the active sort stack, pages
  it, and returns the full-set count, fetching page rows in id chunks so
  the "Alla" page size cannot blow the PostgREST URL limit.
- JournalEntryList: the toggle is now server-backed (refetch on change,
  honest count in the dialog badge); client-side re-filtering against
  late-arriving attachment counts removed. Deep-link arrival turns the
  filter on and scopes the visit to all fiscal years in memory only,
  matching the all-years badge count without touching the saved
  preference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): harden the saknade-underlag filter after skeptic review

Three skeptic subagents refuted the first cut; this fixes every confirmed
finding in one pass:

- FyPicker: new suppressAutoRestore prop. The deep-link visit opens as
  "Alla räkenskapsår" in memory, and FyPicker's on-load restore of the
  persisted year (value === null) snapped the scope back right after
  load, desyncing the list from the all-years badge that launched it.
  Manual picks still persist as usual.
- Voucher-label search: the resolver now carries the same parseVoucher
  OR-branch as the direct list path, so searching "A209" with the filter
  on finds verifikat A209 instead of silently returning 0 rows.
- Staleness while the filter is on: batch exempt, the single-row "Inget
  underlag krävs" toggle, and a row gaining its first underlag now
  refetch in place so fixed rows leave the filtered list and the count
  stays honest (the pre-server-filter behavior). The attachment-driven
  refetch is guarded per entry id against predicate-disagreement loops.
- Drafts view: the filter switch is disabled there; the predicate is
  posted-only and the badge would mislabel the draft count.
- Perf: the bulk-exempt route resolves ids only, skipping the per-row
  total_amount computed column on its full post-import candidate scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): keep the underlag resolver statically checkable

The skeptic-fix commit tripped the phantom-column scanner ceiling
(tests/schema/no-phantom-columns.test.ts, 382 > 380): a computed
select() string and a runtime-built .or() are expressions the scanner
cannot resolve against the schema. Restructured instead of raising the
ceiling: the idOnly/full column choice is two literal select() calls
behind a lazy branch, and a voucher-label search fans out to two
statically-checkable candidate queries (description ilike, series+number
eq) unioned by id, same semantics as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:06:30 +02:00
Jakob Wennberg 2860cd51b1 fix(mcp): vat_amount in transaction currency + nullable matched_supplier_id (#1842)
Two MCP-agent-reported bugs (feedback seq 254607, 261972; also reported
by mail):

- categorize_transaction validated vat_amount against the transaction-
  currency gross but posted the raw figure as SEK: a 15.87 USD override
  on a 79.34 USD Stripe payment booked 15.87 kr to 2611 instead of
  ~150.92 kr, and the entry still balanced so nothing could catch it.
  buildMappingResultFromCategory now resolves the same SEK value the
  line builder uses for the gross and books ALL VAT figures off it:
  the override (scaled by the settlement ratio), the auto-derived rate
  VAT, and reverse-charge fiktiv moms, which had the same defect for
  every foreign-currency transaction. SEK transactions are unchanged.
  The vat_amount schema now states the denomination; the override
  bound error names the currency.

- complete_document_upload / upload_document declared
  matched_supplier_id as a bare string while unmatched uploads
  correctly return null, so strict clients failed every successful
  unmatched upload and tripped the caller's circuit breaker. The
  schema is now ['string', 'null'], matching the runtime.

Catalog token ceiling 59.9K -> 59.95K per the documented ratchet
protocol (prose trimmed to the floor first; headroom was ~19 tokens).


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:43:52 +02:00
Jakob Wennberg 150e2a3f14 feat(reconciliation): agent surfaces, skattekonto notice, bank icons and fair sync order (#1836)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): sign-off, period picker, Hem row and the three doors for it

"Markera som avstämd t.o.m. <datum>" as an append-only attestation:
account_reconciliations (who signed which account through which date,
with the numbers as they stood; reopen stamps instead of deletes; RLS
members write as themselves, viewers read). Policy in one place
(lib/reconciliation/signoff.ts): refused with an unexplained difference
unless forced with a note, refused past today or past the skattekonto
snapshot, refused at or before an active sign-off; reopen is the undo.
Every status read now carries the latest active sign-off and the rail
shows "avstämt t.o.m.".

Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen),
v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run,
registry + regenerated API skill), MCP gnubok_reconcile_signoff (search
catalog, stages reconciliation_signoff after a policy dry run; executor
+ risk tier + op-type CHECK migration pair). Events
reconciliation.signed_off / reconciliation.reopened, and the four
reconciliation events join the public webhook set (additive; API version
unchanged, changelog section added).

Page: räkenskapsår + range picker in the header (own preset memory,
opens on this month) scoping the bridge, the items and the default
sign-off date; sign-off dialog with the forced-with-note path; reopen
on hover. Hem: worklist category reconciliation_due ("Konton att stämma
av"), zero until the company has signed anything off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard

gnubok_reconcile_signoff carries the deliberately separate
reconciliation:signoff scope; the central viewer guard keys on the
:write/:approve/:manage suffixes, so a viewer could reach the tool (RLS
would still refuse the row, but the guard is the intended layer). Add
:signoff to the classifier; the strictness test that caught it now passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): serve local rate-limiter waiters in arrival order

Two callers that both found the in-memory bucket empty each set their own
timeout; the timeouts expired at the same instant from different timer
lists and which woke first was platform-dependent. hydrateInvoices relies
on "started first, requested first" to serve open invoices before paid
ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI
(twice on #1817) while holding locally. A promise queue makes the local
waiters FIFO without changing the rate; the Upstash path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d)

* feat(reconciliation): agent surfaces: summary resource, attention category, reconcile-month skill, skattekonto notice, fair sync order

Accounted://reconciliation/summary: every reconcilable account with its
state, unexplained difference, open counts, last fetch and latest
sign-off, plus a next step; the rail as a resource, on the same service
function the page and v1 use. Accounted://attention gains
reconciliation_due (shared predicate with the Hem row). A reconcile-month
workflow skill and the reconcile_month loadout describe the account-keyed
flow (summary -> bridge -> buckets -> sign-off).

The skattekonto sync persists its reconciliation summary
(skattekonto_reconciliation_latest) so the new Hem notice skv_unexplained
("Skattekontot stämmer inte med bokföringen: X är oförklarat", link to
/reconciliation?account=skattekonto) costs one small read instead of a
bridge computation per render; it honours the drift tolerance and its id
carries the whole-krona amount so öre noise never resurfaces a dismissal.

The skattekonto sync cron orders eligible companies by stalest sync
(never-synced first) before its per-run cap, so the tail is no longer
starved by a fixed order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger preview build (builder OOM during Running TypeScript, not the diff)

* fix(reconciliation): visual pass round 1: full-width table, bank tile shows the period sum

From Jakob's first look at the page on real data:
- The items table now spans the full page width (the approved layout);
  the rail + tiles + bridge + actions stay in the two-column grid above
  it, which now lives inside AccountOverview (the rail rides in as a
  prop) so the table can break out below.
- The bank account's first tile said "okänt": it read external_balance
  (the reported bank balance, often unknown) while its label says
  Banktransaktioner i perioden. It now shows the bridge's period sum,
  matching the label, the difference and the bridge line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): bank brand icons in the rail

The rail resolves each bank account's icon from its connection's
bank_name (falling back to the account name) against square brand icons
committed under public/logos/banks/: the set covers every bank with a
live connection in prod as of 2026-08-24 (SEB, Lunar, Handelsbanken,
Swedbank, Nordea, Svea, Länsförsäkringar, Revolut, Wise, Danske, Klarna,
Northmill, PayPal, plus Stripe for named accounts). Word-boundary
matching so lookalike names never hijack a logo; anything unmatched (the
small sparbanker, file imports) keeps the monogram. The skattekonto
already had its Skatteverket mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): label the bank period sum as netto

Jakob read 'Banktransaktioner i perioden 399 941 kr' as gross activity
(his is ~1,9 MSEK) and rightly asked why it was so low: the value is the
net movement (in - out), which is what the bridge compares against the
net booked movement on the ledger account. Verified against raw prod
data (237 rows, 1 169 126,40 in, -769 185,04 out = 399 941,36). The
tile and the bridge line now say '(netto)' / '(net)'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:08:53 +02:00
Jakob Wennberg f40795896f feat(reconciliation): sign-off, period picker, Hem row and the three doors for it (#1835)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): sign-off, period picker, Hem row and the three doors for it

"Markera som avstämd t.o.m. <datum>" as an append-only attestation:
account_reconciliations (who signed which account through which date,
with the numbers as they stood; reopen stamps instead of deletes; RLS
members write as themselves, viewers read). Policy in one place
(lib/reconciliation/signoff.ts): refused with an unexplained difference
unless forced with a note, refused past today or past the skattekonto
snapshot, refused at or before an active sign-off; reopen is the undo.
Every status read now carries the latest active sign-off and the rail
shows "avstämt t.o.m.".

Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen),
v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run,
registry + regenerated API skill), MCP gnubok_reconcile_signoff (search
catalog, stages reconciliation_signoff after a policy dry run; executor
+ risk tier + op-type CHECK migration pair). Events
reconciliation.signed_off / reconciliation.reopened, and the four
reconciliation events join the public webhook set (additive; API version
unchanged, changelog section added).

Page: räkenskapsår + range picker in the header (own preset memory,
opens on this month) scoping the bridge, the items and the default
sign-off date; sign-off dialog with the forced-with-note path; reopen
on hover. Hem: worklist category reconciliation_due ("Konton att stämma
av"), zero until the company has signed anything off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard

gnubok_reconcile_signoff carries the deliberately separate
reconciliation:signoff scope; the central viewer guard keys on the
:write/:approve/:manage suffixes, so a viewer could reach the tool (RLS
would still refuse the row, but the guard is the intended layer). Add
:signoff to the classifier; the strictness test that caught it now passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): serve local rate-limiter waiters in arrival order

Two callers that both found the in-memory bucket empty each set their own
timeout; the timeouts expired at the same instant from different timer
lists and which woke first was platform-dependent. hydrateInvoices relies
on "started first, requested first" to serve open invoices before paid
ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI
(twice on #1817) while holding locally. A promise queue makes the local
waiters FIFO without changing the rate; the Upstash path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d)

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:07:58 +02:00
Jakob Wennberg 3a62c5419e feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools (#1833)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:03:20 +02:00
Jakob Wennberg 0a8544e0cb feat(reconciliation): account-keyed engine: one bridge for bank and skattekonto (#1813)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:55:08 +02:00
Jakob Wennberg 7cf15a105f fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account

A transaction with no cash_account_id booked its bank leg on the
hardcoded 1930 from the standard templates and category mappings even
when the company's only bank account is e.g. 1920 (PlusGiro), while the
booking dialogs previewed the right account via the client-side
resolveAccount fallback. resolveSettlementAccount now mirrors that
fallback: with a NULL cash_account_id it lists the company's enabled
cash accounts and, when EXACTLY ONE matches the transaction's currency,
settles there; zero or several candidates keep the 1930 fallback. The
explicit-cash_account_id branch (including its throw-on-error path,
issue #842) is byte-identical. Transaction currency is threaded into
the categorize, batch-categorize, pending-operation edit, MCP staging,
and invoice-inbox call sites; other callers get the SEK default.

Forward-only: historical wrong verifikat are corrected only via the
existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md).

Fixes #1722

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

* test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups

The merge of main (PR #1828) into this branch combined two changes that each
add one query to the categorize commit flow; the strictly ordered queued mock
in the allow_duplicate test needed the cash_accounts listing entry inserted
between the period lookup and the pre-FY clamp lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:26:08 +02:00
Jakob Wennberg 8e3015e541 fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.

Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.

Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
  OPEN unlocked fiscal period with entry_date = period_start and appends
  "Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
  verifikationstext. Interior gaps, future dates, and a closed/locked
  first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
  calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
  forsta dag (<date>)" for pre-FY dates instead of proposing a
  pre-registration year; "Skapa rakenskapsar" remains for the other
  no_period cases.

No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.

Fixes #1825


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:50 +02:00
Jakob Wennberg 62135c0c02 fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)
A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice
left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning
dialog + MCP path) had no öre handling, so a sub-krona overshoot was
rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice
in partially_paid forever. Invoices already stuck that way had no exit:
the mark-paid dialog proposed clearing the full total (rejected with
MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid
outright.

Fix, both halves:

1. New migration 20260824120000 replaces match_batch_allocate with the
   same öresavrundning band every single-payment path already uses
   (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot
   rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff
   clears the FULL remaining off 1510/2440, books the residual to 3740
   with correct polarity per side, records the full remaining as paid
   and flips the status to paid. >= 1 kr diffs keep today's behaviour.

2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual
   invoice gets a proposal clearing the actual remaining, and a
   sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so
   one click closes a stuck invoice. The mark-paid route and the
   invoice-detail button now accept partially_paid (the settle layer's
   CAS guard always did).

Forward-only: already-stuck invoices are not auto-repaired; they are
closed via the new dialog proposal.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:32 +02:00
Jakob Wennberg 6aecbdc9b7 fix(agent): surface empty assistant answers instead of silent stops (#1830)
The support chat showed 'Tänker' and then went quiet with no answer and
no error. Root cause: the 2026-08-21 RIP-3 cutover moved general.help to
the single-call POST /api/agent/ask, whose 1500-token default cap made
stop_reason max_tokens routine on tool-loop turns. The empty answer then
passed unlogged through the service, the route answered 200 with an
empty string, and the console appended an invisible empty bubble.

Fixes, single-call path:
- ask-service: default maxTokens 1500 -> 5400 (the streaming chat's
  reply ceiling); an empty final answer now logs model + usage and
  throws the typed EmptyModelAnswerError instead of passing through.
- /api/agent/ask: maxDuration 300, logger, empty answer maps to 502
  with 'Assistenten gav inget svar. Försök igen.'; an empty assistant
  turn is never persisted (the question stays, so retry works).
- AskConsole: a 200 with an empty answer shows the error box instead of
  appending an invisible bubble.
- anthropic-family: serialized tool results are bounded at 40000 chars
  (mirrors run-turn) so one big read cannot eat the output budget; the
  step-exhausted fallback keeps tools declared with tool_choice none,
  because replaying tool_use/tool_result without tools is an API 400.

Fixes, streaming path (same silent class):
- /api/agent/invoke: maxDuration 300 so deep thinking turns are not
  killed mid-stream at the platform default cap.
- run-turn: stop_reason max_tokens with no visible text emits an error
  event, not a bare turn_complete.
- AgentChat: an NDJSON stream that ends without turn_complete or error
  (and was not aborted) shows 'Anslutningen bröts innan svaret blev
  klart. Försök igen.'

The lib/ai request-shape tests were updated deliberately for the
fallback change; general.help stays on the single-call runtime
(founder decision, not reverted).


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:15 +02:00
Jakob Wennberg 78525bd391 fix(invoices): make self-billed invoices creditable and their dates visible (#1827)
A self-billed invoice has invoice_number null by design (the counterparty's
number lives in external_invoice_number), which broke the whole credit flow:
the confirm input was disabled and compared against null, the API minted the
literal number 'KR-null', and the credit-note PDF dropped its ML 17 kap 22
reference to the original. The editor also hid fakturadatum inside the
collapsed Forval panel, so self-billed invoices silently registered with
today's date and, being immutable, could not be corrected.

- creditConfirmNumber() falls back to external_invoice_number; the credit
  page uses it for reason default, subtitle, original row, preview, confirm
  label/placeholder/disabled, mismatch check and submit gate
- createCreditNote numbers 'KR-<external>' for self-billed originals and
  refuses with typed 400 INVOICE_CREDIT_NO_NUMBER when no number exists
- mark-sent and send select external_invoice_number and fall back for the
  credit-note PDF's reference to the original
- the Forval chip line now shows the invoice date in every mode, and
  self-billed mode renders fakturadatum + mottagningsdatum uncollapsed as
  transcription fields next to the external number

Fixes #1820


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:18:57 +02:00
Jakob Wennberg 21c63b8b12 fix(enable-banking): carry dedup scope and cash account across a no-IBAN uid change on reconnect (#1826)
Root cause (issue #1709 residual corner): on an in-place reconnect, the
callback carries each account's external_id dedup scope by matching prior
accounts by IBAN or uid. A no-IBAN account whose ASPSP minted a new uid
matched neither, so it got a fresh scope: every historical external_id
regenerated, Layer-1 dedup missed the re-import, and the whole history
came back as new unbooked rows. The cash_accounts mirror then could not
find the old row either and allocated an overflow 19xx slot plus a NEW
row, which also blocked the content-dedup bridge's account guard.

Fix: pair such accounts by elimination, only when unambiguous (per
currency, exactly one unclaimed prior and exactly one fresh-scope new
account, neither with an IBAN): carry the prior scope and enabled flag,
and reuse the connection's own old cash_accounts row via the existing
explicit reuse_cash_account_id promote path in upsertFromPsd2, so the
row id, ledger, and transaction links survive the uid change. Any
ambiguity keeps the previous fresh-scope behavior. Also count
account-incompatible same-feed orphaned ids in the scope-drift shadow
(log-only) so fleet validation can see this incident class.

IBAN-carrying accounts were already fixed by #1705/#1728; the frozen
external_id format is untouched.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:11:13 +02:00
Mattsson 2fd58c4125 feat(pending): queue order toggle, entry date + notes in review, account names everywhere (#1812)
* feat(pending): queue order toggle, entry date + notes in review, account names everywhere

Four review-queue gaps reported by a customer approving bokslut batches:

- Oldest-first toggle: /api/pending-operations accepts order=asc|desc
  (default desc); the queue header gets an Äldst först / Nyast först
  button, remembered per browser (localStorage pending.sortOrder).
- Fiscal year visible: categorize previews now carry the transaction date
  (preview_data.date) and render a Datum row, so two open years are
  distinguishable.
- The agent's `notes` (audit-trail context) is shown in the detail panel
  as Anteckning; before, it was stored in params and never rendered.
- Account names: VoucherLinesTable and PreviewKonteringTable fall back to
  the chart name from AccountNamesContext (6110 Kontorsmateriel · AMAZON
  PRIME instead of the bank text alone); useAccountNamesSource moves to a
  shared hook so the chat ApprovalCard provides the same names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): call useAccountNamesSource in ApprovalCard

The provider referenced accountNames without the hook call; the core build
(tsc) caught it. Local tsc had not, so this also re-runs the full check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:54:23 +02:00
Mattsson d35c401c0c fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message (#1811)
* fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message

gnubok_reverse_journal_entry / gnubok_undo_sie_import cap `reason` at 500
characters, but exceeding it produced code UNKNOWN_ERROR with message_sv
"Något gick fel. Försök igen." while the cause sat only in message_en.
getStructuredError now infers VALIDATION_ERROR from the message and
getErrorMessage maps it to "Motiveringen får vara högst 500 tecken.".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(errors): pin the reason-length pattern to 500 and assert the envelope on undo_sie_import

Review findings: the Swedish message hard-codes 500, so the pattern must
match that limit only; the undo_sie_import 501-char test now asserts code
and both localized messages like the reverse test does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:05:33 +02:00
Mattsson 158ef0f484 feat(mcp): gnubok_list_cash_accounts, a search-only discovery tool for bank accounts (#1810)
Follow-up to #1809: transaction listings now carry cash_account_id, but an
agent had no way to learn which cash accounts exist or which BAS ledger
each maps to. The tool lists cash_accounts (cash_account_id, ledger_account,
name, currency, iban, is_primary, enabled, source), optionally enabled
only. Search-only (catalogVisibility 'search') so tools/list stays inside
its context budget; gnubok_search_tools finds it on "bank account"/"cash
account". Scope transactions:read.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:39:29 +02:00
Mattsson d4c42fd8db fix(bookkeeping): journal search finds a voucher by its label, with a spinner while searching (#1808)
"Sök verifikationstext" only matched journal_entries.description, so typing
A209 never returned voucher A209 itself: only other vouchers whose text
mentioned it. Users read that as "the voucher is missing".

A label-shaped needle (A209, a 209, A-209) now also matches
voucher_series + voucher_number via a PostgREST OR (needle double-quoted so
commas/parentheses stay literal); other needles keep the plain description
ILIKE. parseVoucher accepts one space or hyphen between series and number.
The search box shows a spinner while a server-side search is in flight;
before, the only signal was the list dimming.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:33:24 +02:00
Mattsson 9622382579 fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver (#1807)
* fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver

gnubok_link_transaction_to_journal_entry failed reproducibly for a customer
on certain incoming payments with a bare LINK_TX_DB_ERROR: the service put
the Postgres message in details.reason, but the code had no structured
entry and the commit dispatcher dropped executor data on failure, so
neither the MCP approve result nor result_data said why.

LINK_TX_DB_ERROR now has a structured entry; the executor appends the DB
reason to the message and sets errorCode; the dispatcher persists and
returns executor failure details (result_data.details, CommitResult.data,
.code); gnubok_approve_pending_operation exposes error_code. The next
failing call tells us which constraint or trigger fired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): keep tools/list under the context budget (drop approve schema descriptions)

The two output-schema descriptions added for data/error_code pushed the
projected tools/list payload 7 tokens over the ceiling guarded by
payload-size.bench.test.ts. The fields stay; the prose goes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pending-ops): log loudly when the terminal rejected write fails

Review finding: the rejection branch wrote pending_operations without
checking the result, so a failed write left the row in 'committing' with
the executor error, code and details lost silently. Mirror the finalize
branch: inspect the write result and log with the ids plus the failure we
could not persist; the daily recovery sweep still resolves the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:24 +02:00
Mattsson 1f6dd778e5 fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN (#1806)
* fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN

Invoice PDF and invoice email resolve payment details by invoice currency
(invoice_payment_accounts, #1116), but the overdue-reminder templates still
read the legacy company_settings fields, so a EUR reminder printed the SEK
account's IBAN. A customer has paid to the wrong account from one.

Both reminder generators now go through companyWithInvoicePaymentAccount,
and sendReminder applies the same gate as invoice send: no usable account
for the invoice currency means the reminder is skipped with
INVOICE_PAYMENT_ACCOUNT_MISSING:<currency> in the result, instead of going
out with no (or the wrong) payment details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): run the reminder payment-account gate before the fee entry and reminder row

Skeptic refutation (3/3): the gate lived in sendReminder, after
processOverdueReminders had already posted the 60 kr påminnelseavgift
verifikat and inserted the invoice_reminders row, so a skipped EUR reminder
booked a fee for an email never sent and burned the level for good. The
gate now runs in the loop before any write; sendReminder keeps it only as
a backstop for direct callers. New processor test asserts no fee entry, no
row and no email for the skipped case, and all three once an account exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:17:27 +02:00
Mattsson 0040cadacc feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:07:30 +02:00
Jakob Wennberg dc5079a912 fix(providers): stop inventing 25% VAT on migrated invoices (#1745)
* fix(providers): stop inventing 25% VAT on migrated invoices

An invoice migrated from Fortnox displayed "Momsbehandling: 25 % moms"
next to "Moms: 0 kr", with no line items behind it. It was not a display
bug: the record really did hold vat_rate 25 and vat_amount 0.

Fortnox answers GET /3/invoices with the short form, which carries no
Net, no TotalVAT and no InvoiceRows; those live only on the detail form.
The migration mapped the list payload alone, so `Net ?? total` made the
net equal the gross, VAT derived as gross minus net came out 0, and with
no rows to read a rate from, inferVatTreatment/inferVatRate fell through
to their `return 'standard_25'` / `return 25` defaults. The result
balanced, so nothing downstream noticed.

Measured on prod: 8 712 sales invoices across 43 companies assert a rate
beside 0 kr of VAT (286 MSEK of subtotal), plus 1 240 supplier invoices.
None are booked, but 263 are still open, and the no-items booking
fallback in invoice-entries.ts credits the full gross to 30xx and emits
no 2611 line at all.

Not Fortnox-only. Visma reported its VAT-inclusive TotalAmount as the
ex-VAT amount and read rows via `LineTotal`/`VatRatePercent`, neither of
which exists in the eAccounting schema (the real names are AmountNoVat
and PercentVat), so its lines all landed at 0. Bjorn Lunden reported the
gross as the net with no lines at all. Briox and WINT had the same
gross-as-net fallback, and Bokio defaulted a missing totalTax to 0.

- lib/providers/amounts.ts: readers that return undefined for an absent
  field, so "the provider says zero" stays distinct from "did not say"
- every mapper: populate taxTotal and per-line taxAmount from what the
  payload actually states; leave the net undefined when it does not
- provider-data-fetcher: hydrate the detail endpoint that every config
  has always declared and nothing ever called, open invoices first,
  within a time budget, reporting whatever it could not reach
- entity-mapper: derive rate and treatment from evidence; when there is
  none, write vat_rate null and flag vatUnresolved instead of asserting
  a standard rate

Existing rows are untouched; repairing them needs a separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): keep subtotal + VAT equal to the invoice total

Providers state net, VAT and gross independently and they need not agree:
Fortnox's Total is the amount to pay after öresavrundning while
Net + TotalVAT is the unrounded Gross, so the two differ by up to 50 öre.

Passing both through as stated put that gap into the invoice row, where
subtotal + vat_amount no longer equalled total. The header booking path
in invoice-entries.ts derives the 1510 debit from the sum of its credits,
so the receivable would land a few öre away from what the customer owes
while the verifikat still balanced: the same silent shape as the bug this
branch fixes.

resolveVatTriple now always returns a pair summing to the gross, keeping
the VAT intact (it reaches the momsdeklaration) and absorbing the
rounding into the net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): address invoice detail by the configured idField

Hydration built the detail path from dto.id. Björn Lundén's sales config
names invoiceNumber as its idField while its mapper builds dto.id from
entityId, so BL sales invoices would have been hydrated from the wrong
resource, or from none. Every other provider/resource pair happens to
agree on the two, which is what made the mismatch easy to miss.

The config's idField is the authority, read off the raw payload, with
dto.id only as the fallback. The regression test uses BL with entityId
99001 and invoiceNumber 5 so the two cannot coincide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): store vat_rate null for migrated mixed-rate invoices

resolveInvoiceVat labelled the header with the first line's rate, so an
invoice carrying both 25 % and 6 % lines was recorded as a 25 % invoice.
buildInvoiceWriteData already stores isMixedRate ? null : theRate for
natively created invoices; migrated ones now match.

The money was already right and stays right: generatePerRateLines groups
per item rate, so a mixed invoice books 25 % and 6 % separately off the
per-line vat_rate/vat_amount this branch fixed. Only the header label was
overstating what the source said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): bound hydration against auth failures and the clock

Two failure modes that only appear against a real provider.

A 401 or 403 fails identically for every remaining invoice, so the pass
now stops on the first one instead of issuing hundreds more doomed
calls. That matters more than it looks: TokenBucketRateLimiter keys on
the literal string 'global', so Fortnox's 4 req/s is a platform-wide
budget shared by every company and every concurrent migration, not a
per-token one. A 404 is about one invoice and does not stop the pass.

The budget was checked before starting a call but never during one. The
clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s
apart), so a call starting one millisecond inside the budget could still
be retrying minutes later, and three concurrent ones could hold the
migration past its 300 s function ceiling. Each call is now raced
against the deadline; the socket is not cancelled, but control returns
and the remaining invoices are reported unhydrated instead of the run
dying.

Both outcomes are reported as HydrationReport.abortedBy so a partial
pass is visible rather than looking complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <invoice@arcim.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:07:52 +02:00