Commit Graph

1028 Commits

Author SHA1 Message Date
Jakob Wennberg 7448490fb7 fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298)

The invoice-to-verifikat link is written on the invoice side only
(invoices.journal_entry_id, invoice_payments.journal_entry_id), while the
missing-underlag predicate and the periodisk sammanstallning resolved the
invoice from the entry's own source columns. A SIE-imported sale matched to
its invoice afterwards therefore kept warning "Underlag saknas" and was left
out of the EU sales list, although the account-based momsdeklaration showed
it and the verifikat page already listed the invoice as its underlag.

- verifikat_without_documents / transactions_without_documents: customer-
  invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row;
  new migration 20260906135702, pinned by a pg-real test.
- getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used
  by the journal-list filter and bulk exempt, /api/documents/counts (new
  invoice_references map) and the transactions list; the push cron mirrors
  it with its global reads.
- Journal list: no "Underlag saknas" chip for a covered entry, matching
  the engine's own invoice rows and the verifikat detail page.
- Periodisk sammanstallning: entries fetched by their EU-revenue lines and
  attributed through every link (engine source_id, invoices.journal_entry_id,
  invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment
  entries are filed too, which the old source_type filter dropped.

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

* fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review)

- The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron)
  now require an ISSUED invoice: status not in ('draft', 'cancelled'), the
  schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_
  STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the
  pg test pins a draft-linked and a cancelled-payment entry as still missing.
- Periodisk sammanstallning: one verifikat linked to invoices of different
  customers is no longer attributed to the first invoice; it is left out of
  the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT
  naming the voucher, the customer count and the amount. Same-customer
  settlements are filed in full.
- Transactions list: a failed invoice-reference lookup leaves that chunk's
  verdict unknown (no badges) and continues with the remaining chunks instead
  of abandoning them.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:04:30 +02:00
Jakob Wennberg cce0de5704 feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate

The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when
posted, unlinked vouchers already summed to the bank row (PR #2300), but the
MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate)
called the RPC with no guard, so an agent could book a Bankgirot aggregate a
second time. The detector existed once; the guard lived in one door.

One shared decision helper, lib/invoices/already-explained-guard.ts, now
sits on top of the existing detectors (no fork) and is called by the
dashboard route, the MCP staging tools and the commit executors:

- gnubok_match_batch_allocate refuses to stage, coded
  BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match /
  link_transaction_to_journal_entry call that resolves the row, and the
  exact force + expected_journal_entry_ids binding.
- commitMatchBatchAllocate runs the same guard before the RPC and
  re-validates a staged force binding against the set detected at commit,
  so a stale approval cannot book a duplicate; 409 auto-rejects with the
  vouchers in result_data.
- force + expected_journal_entry_ids on the tool mirror MatchBatchSchema;
  an honoured override stages with a compliance_warning and, after the
  booking succeeds, writes BankTransactionDuplicateDismissed to
  behandlingshistorik (dashboard route included; it only logged before).
- gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get
  the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE
  / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force +
  expected_journal_entry_id; at commit it runs before the storno.
- Registry: both duplicate codes gain retryable: false and a remediation.

Catalog payload held under the 60K ceiling by trimming the two tools' own
descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts).

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

* docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices

Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file.

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

* fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review)

Review round on PR #2346 (CodeRabbit + compliance):

- guardAlreadyExplained returned 'clear' when the detector threw even with
  force=true, so a forced 1:N override could book without re-validating
  expected_journal_entry_ids and left no behandlingshistorik record. It now
  returns a distinct 'unverifiable' outcome under force (mirrors
  guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool
  and the commit executor all refuse it with the new registry code
  BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression
  tests on every caller.
- A detector failure without force still fails open at stage time, but no
  longer silently: the tools track onDetectError and stage a
  complianceNote, so preview_data.compliance_warning is set on both
  match_batch_allocate (GenericPreview renders it) and
  match_transaction_invoice (MatchTransactionInvoicePreview now renders
  data.compliance_warning through AttnLine).
- expected_journal_entry_ids / expected_journal_entry_id are validated at
  the MCP boundary (array of 1 to 10 non-empty strings / non-empty string)
  and refused with VALIDATION_ERROR instead of being silently filtered.
  No schema description text added: catalog payload unchanged.
- RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history
  for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR
  2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:56:36 +02:00
Jakob Wennberg 9cb1d105e3 fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)
* fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them

Self-hosted deployments on an OpenAI-compatible provider (or with no AI
configured) still showed every entry point into the tool-loop runtime
behind /api/agent/invoke, which answers 503 there. The capability lived
server-side only (getAiStatus().assistantAvailable); no UI could read it.

Hand the flag to the client through CompanyContext (useAssistantAvailable,
beside the paid-capability gate) and gate each entry point that opens
AgentChat: the bookkeeping page's "Skapa med assistent" and "Med
assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake
and /chat/new?intent=. The floating trigger falls back to general help (the
single-call console runs on any provider) instead of hiding, and AgentChat
itself never fires an invoke without the runtime, so a resumed thread or a
forgotten entry point shows a notice instead of a 503. The Hem checklist's
"Anslut till Claude" step renders only where the assistant runs on Claude
and the mcp-server extension is on. Provider-agnostic AI (ask console,
categorization, extraction) and the server-side 503 are unchanged; on
hosted the flag is true and nothing changes.

Closes #2204

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

* test(ai): use a placeholder that cannot match an Anthropic key shape

The new direct-Anthropic status test assigned a string in the exact
format of a live API key, which trips secret scanners on every run. The
config only reads presence, so any non-empty string exercises the path.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:54:47 +02:00
Jakob Wennberg 272d19b287 fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side (#2299) (#2345)
* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side

The mark-paid guard probed merchant_name for the FULL supplier name, so the
row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never
matched and the payment was booked twice (#2299).

- counterpartyNeedle(): first distinctive token of the name (alnum, legal
  forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on
  merchant_name OR description in one .or() per currency sweep; the alnum
  shape is what makes the DSL interpolation safe.
- findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer
  detector; both share the sweep and the scorer. The dashboard route's inline
  copy is deleted; the v1 supplier mark-paid door gets the guard it lacked.
- New match_reason already_booked (row already carries a verifikat, booked
  straight from the bank side): ranked first, carries journal_entry_id, and
  the dialogs, MCP path and pending-operation commit word the remedy as a
  rattelse rather than "link it".
- Customer side gets the same token prefilter and classification.

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

* test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard

The customer detector now issues one .or() counterparty probe per currency
sweep instead of two ILIKE queries, so every queued answer after the guard
was consumed one step early: the aggregate-sweep [] became company_settings,
the settings row hit the entry builder, and two tests saw 500 / the wrong
voucher id. Each guard block now enqueues one probe plus the aggregate sweep;
the 409 tests drop the second-probe entry that is no longer read.

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

* fix(invoices): one logic expression per duplicate-payment sweep, never two or= params

The sweep chain carried two .or() calls (currency clause, then name probe).
postgrest-js appends a query parameter per call, so the client sent or=
twice, and whether PostgREST ANDs a repeated key was never proven in this
repo; had it kept one, the currency predicate would be gone and foreign rows
banded against a kronor figure.

counterpartySweepLogic() now nests both groups under one and() inside a
single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*,
description.ilike.*x*)). The sweep issues exactly one .or() per currency.

Proof at three levels: unit tests pin the helper's string; a fake-fetch test
runs the real postgrest-js builder and asserts exactly one or= search param
per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit
(with an amount_sek that would pass every JS check) and right-currency+miss
rows against a real PostgREST and asserts, for both detectors and both
sweeps, that only the first comes back, from PostgREST's own response.

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

* fix(invoices): name storno as the already_booked remedy, never "makulera"

A posted verifikat is never deleted; it is corrected by a storno entry
(BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the
MCP and pending-operation messages and both UI descriptions now say so:
"vänd en av verifikationerna med storno och koppla underlaget till den som
blir kvar" / "reverse one of the two vouchers with a storno entry and attach
the underlag to the remaining one".

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:53:34 +02:00
Jakob Wennberg 82d25b7dee fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar (#2213) (#2344)
* fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar

The Betalningar row on the customer invoice page rendered "Inga
registrerade betalningar ännu" whenever invoice_payments had no row, even
under a header saying Betald / Betalning mottagen / Återstår 0 kr. A
migrated invoice that was paid in the previous system is exactly that
state: the provider migration writes status, paid_amount and paid_at from
the source and nothing else, while invoice_payments is written only by
Accounted's own settlement paths (all fail-closed). "Settled with zero
rows" therefore never means "no payment yet"; it means the payment was
recorded where this ledger never saw it.

lib/invoices/payment-history-gap.ts classifies that state from the data
(no provenance column): settled + zero rows + no posted invoice_paid /
invoice_cash_payment voucher keyed on the invoice renders one line,
"Betald {date}, före migreringen till Accounted" (partial and undated
variants); settled + zero rows + such vouchers (#2019 leftovers) lists
the vouchers in place of the rows, linked to the verifikat; a failed
lookup says the history could not be loaded instead of asserting either.
payment_status_empty is removed from both locales: no reachable state
renders it any more.

Closes #2213

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

* fix(invoices): log a failed payment voucher lookup instead of returning null silently

fetchInvoicePaymentVouchers returned null on a DB error with no trace, so
a failure behind "Betalningshistoriken kunde inte hämtas" was invisible.
Warn with the invoice id and the error code/message (no invoice content),
and assert it in the test.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:50:57 +02:00
Jakob Wennberg b2d3a3e273 feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) (#2337)
* feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287)

Inställningar > Bokföring > Räkenskapsår offered Lås, Nollställ and Skapa
nytt räkenskapsår but no way to change the name or dates of a year that
already exists, although PATCH /api/bookkeeping/fiscal-periods/[id] has
supported both (name always on an open year, dates only while the year has
no posted vouchers). The only surface over that route was the first-year
date editor under Företag, so a later or backfilled year saved with the
wrong name or dates (Aisen & Adison AB, #2286) could only be repaired in
the database.

Every open year row now gets a quiet Ändra action opening one dialog
(Namn, Startdatum, Slutdatum) that posts only the changed fields to the
existing route. Dates are read-only with the route's own reason when the
year has posted vouchers (count from the entry-count endpoint); the name
is always editable. Locked and closed years get no Ändra, matching the
route's refusal and the row's chip. Route refusals are shown inline so the
user can correct and retry.

The name follows the dates while it still has the shape fiscalYearName()
produces (new isDerivedFiscalYearName, the inverse predicate next to the
one naming helper): the customer's "Räkenskapsår 2027" corrects itself to
"Räkenskapsår 2022/2023" as the dates are fixed, and a hand-written name is
never overwritten. Saving invalidates ref:fiscal-periods so every picker
updates; the reset dialog's typed confirmation reads the name live from
the reset snapshot, so a rename does not break it.

No route change. New strings in both messages/sv.json and messages/en.json.

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

* fix(settings): fiscal-year edit dialog is not dirty on open when the stored name has stray whitespace

"Changed" now compares the raw input with the stored name and sends the
trimmed value only once the user has edited it; before, a stored name with
leading or trailing whitespace enabled Spara on open with a trimmed name
in the payload (CodeRabbit on #2337).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:48:27 +02:00
Jakob Wennberg 162de2128a fix(import): show "created on import" as the mapping target for source accounts the chart lacks (#2342)
The guided Fortnox import self-mapped a source account that exists in
neither the company chart nor BAS (4599) and then rendered its Malkonto
select blank, because the dropdown only knew chart + BAS accounts. The
row looked unmapped and unmappable while the import created the account
correctly. A nameless account (referenced by #TRANS without #KONTO) was
worse: the mapper refused the self-map, so it stayed unmapped with no
self-target to pick.

- account-mapper: the bas_range self-map no longer requires a #KONTO
  name; unmapped now means exactly "outside 1000-8999". isValidBASRange
  exported as the auto-create boundary.
- AccountMappingStep (shared by both wizards): a target the list cannot
  name is an explicit "<nr> <name> (skapas vid importen)" option, a
  "nya konton skapas" badge/filter lists them, out-of-range accounts
  that block Continue are named, nameless sources say so.
- sie-import: skippedVouchers.unmappedAccounts (per account, voucher
  count) via summarizeUnmappedSkips; warning names the accounts.
- Migration result step: names created accounts and the accounts behind
  "med ej kopplade konton".

Closes #2212


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:39:36 +02:00
Jakob Wennberg ea4da0eb07 fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)
Part of #2059 (Part 2, the hardening bug).

The FK on invoice_items.article_id proves the article exists, not that it
belongs to the writing company: FK validation ignores RLS, and the v1 routes
run on the service-role client with no RLS at all. Only the two MCP commit
executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and
sales-order writers passed items[].article_id straight through the builder.

Move the check to the one point every writer converges on: buildInvoiceWriteData
collects the distinct article ids from product lines, runs one select scoped
on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400,
Swedish message via the structured-error registry) on any miss. The MCP
executor checks stay as the tamper gate for staged rows.

Tests: builder unit cases (miss refused with details, dedupe + happy path,
no article ids means no query, DB error surfaces as dbError), cookie PATCH
and v1 POST refusal cases, and the existing v1 persist + MCP update tests now
answer the builder's scoped select.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:39:08 +02:00
Jakob Wennberg b71f2bf425 feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line

The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).

- create_expense_payout_batch(..., p_transaction_id): old signature dropped
  so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
  TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
  outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
  primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes

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

* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma

- "Matcha mot utlägg" in the inbox row menu: pick the person and the
  receipts a transfer covers when the exact-amount pairing missed it. The
  picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
  foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
  from Att göra, the attention resource, suggestions and the picker; a
  payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
  the dialog says so.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:16:51 +02:00
Mattsson 238cbe13f9 feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules

A yearly or quarterly recurring schedule had no way to say which month it
bills in: the dialog exposed interval and day of month only, so a yearly
schedule created in September always fired in September. The phase of a
schedule is fully defined by its first run date, which the table already
stores as next_run_date and the create API already accepted as start_date
but nothing exposed.

- Dialog: new date field (first invoice date on create, next invoice date on
  edit), prefilled with the next natural occurrence so the default is "no
  offset"; kept in step with day of month both ways; shows the following
  three run dates so the phase is visible. Sent as start_date on create and
  as next_run_date on edit only when the user actually re-phased.
- API: create validates start_date (on the day_of_month grid, not in the
  past); update accepts next_run_date (on the grid for the effective day,
  strictly after today in Stockholm) and lets it win over the automatic
  recompute a day change or reactivation does.
- Staged operations / MCP: start_date documented as the phase; update tool
  gains next_run_date. Commit executor rejects off-grid dates and rolls a
  date that went stale before approval forward on its own grid.
- lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared
  by the dialog, the routes, the executors and the cron service.

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

* fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog

Resolves the skeptic and CI findings on #2338 in one pass:

- MCP staging tools now apply the same grid and past/future rules as the
  routes to start_date and next_run_date, so the preview a human approves
  is exactly what the commit executor writes (previously an off-grid date
  staged fine and failed at approval, and a past next_run_date was rolled
  to another date silently).
- The dialog computes today and the default first invoice date in
  Europe/Stockholm instead of the browser's zone, matching the server;
  getStockholmDateHour moved to the client-safe module and is re-exported
  from the service.
- gnubok_update_recurring_schedule description trimmed under the 280-char
  limit while keeping the clamping and Stockholm phrases the registration
  test requires.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:49:29 +02:00
Mattsson 0c854ac54f feat(settings): let a company name each verifikationsserie letter (#2336)
* feat(settings): let a company name each verifikationsserie letter

The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.

- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
  values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
  never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
  values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
  letter is called: company name, then preset, then empty.
  buildVoucherSeriesOptions replaces the three near-identical option
  builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
  union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.

Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.

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

* fix(settings): keep imported series in the list and unsaved names through a refetch

Skeptic pass on the series-name editor refuted two things:

- The rewritten list filtered voucher_sequences to single letters, dropping
  multi-character series (FT, LB, SKV, ...) that 54 production companies
  carry over from Fortnox and Bokio imports; the old list showed them with
  their highest number. Rows are now every used series plus the configured
  and named letters; only single-letter series get a name input, since
  those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
  and the settings hook revalidates on window focus with a fresh object, so
  unsaved typing was wiped after any earlier save on the page. The re-seed
  is now keyed on the serialized content of the saved names.

Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.

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

* fix(settings): name the default-series options and enforce the label shape in the database

Review pass on #2336:

- CodeRabbit: the Standardserie selector under Bokföring still rendered
  bare letters; it now shows the same name the other pickers do, through
  voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
  voucher_series_labels lived only in UpdateSettingsSchema. Migration
  20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
  object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
  non-blank strings of at most 40 characters), so a write that bypasses
  /api/settings cannot store a map the pickers cannot handle. Applied to
  staging with its schema_migrations row; verified against good, empty,
  lowercase, blank, over-long, numeric and array inputs.

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

* test(pg): cover the voucher_series_labels CHECK against real Postgres

The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:20:42 +02:00
Jakob Wennberg 0e3c0af841 fix(sie-import): refuse a closed or locked target year up front and point at Öppna igen (#2334)
A SIE file whose #RAR falls inside an existing fiscal year answered
'match' in precheckFiscalPeriod without looking at is_closed or
locked_at, so the import ran into the atomic voucher RPC and surfaced
the DB trigger's own text ("Cannot write to locked/closed fiscal
period"). Observed 2026-09-04: an owner klarmarkerade an empty prior
year, could not import its single aktiekapital voucher, and never found
the "Öppna igen" button that undoes klarmarkera.

The precheck now returns a conflict verdict for a closed or locked
containing year, with the remedy per state: Öppna igen for a
klarmarkerad year, Lås upp for a locked one, and no false hope for a
year closed by a year-end run. The parse preview shows the same text
and disables the import; executeSIEImport's no-create branch gets the
same refusal.

The årsredovisning builder now warns when the comparison year exists
but holds no entries in Accounted: the column reads 0 kr, ÅRL 3 kap.
5 § requires the prior year's amounts, and the warning says where the
fix lives instead of printing zeros silently.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 19:13:28 +02:00
Jakob Wennberg b0b995c77c fix(tab-guard): name the company the other tab switched to (#2328)
The cross-tab guard dialog said "another tab switched the active company"
without saying to which one, so the two exits read as "your company" versus
"the new one". Resolve the observed company id against the memberships the
shell already ships to the client (switcher list plus the foreign-host
signpost list; no request at the moment the tab is told to stop) and say
"en annan flik har bytt till Demo AB" and "Ladda om som Demo AB". Unknown
ids keep the unnamed wording.

Founder re-confirmed the blocking two-exit design (WL-09) today after a
forensic pass on a real firing; this is copy only, no behaviour change.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 17:27:08 +02:00
Jakob Wennberg 473b1fd2eb fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)
* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials

Every Björn Lundén connect in prod has failed with "Leverantören avvisade
autentiseringen" (10 consents since June; only BL's own sandbox company ever
received tokens). Live-verified against a real customer User-Key today: BL
answers 403 "<service>:READ is out of allowed scope for service provider
Arcim" on every read endpoint. The key is right and binds the company; the
company has simply never activated our integration, and it cannot until BL
moves the listing out of sandbox. The generic 403 mapping told the user to
re-check what they pasted, which can never help.

- BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError,
  matching the verbatim live 403 and 500 bodies.
- submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind
  'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our
  own client_credentials token refused) rethrows as a generic submit failure
  instead of blaming the pasted key.
- New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and
  BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix
  (activate under Integrationer in Lundify, else SIE) and where the GUID is.
- Wizard copy for BL moved to i18n keys and reordered: activate first, then
  paste the key; the key only works once the integration is activated.
- Tests: route mapping for both kinds, probe classification incl. the
  captured live bodies, registry entries pinned to 422.

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

* fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable

Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up
User-Key answered 500 with a Spring BeanCreationException for
databaseConnector, not the null getCurrentUser() message captured earlier.
The unknown-key verdict already keys on the status alone in
submitProviderToken; keep only the 403 scope matcher, whose body IS stable.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 17:20:08 +02:00
Jakob Wennberg 41a5728ca7 fix(expenses): review follow-ups from #2317 (#2326)
- The Utlägg nav row is computed server-side, so the first booked claim
  now refreshes the App Router tree instead of staying hidden until a
  full reload.
- The dialog's default date is the local calendar date; toISOString() is
  UTC and dated a receipt booked after midnight CEST to the previous day.
- listExpensePayoutsDue pages through every registered claim with
  fetchAllRows instead of stopping at 500 rows: a person omitted or a
  total understated there is money the company owes someone.
- The attention resource's payout instruction names the liability
  account per row (2893 / 2018 / 2820) instead of only 2893/2820.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:43:53 +02:00
Mattsson eb2ae1da17 fix(invoices): render statutory PDF notices in the document language (#2321)
* fix(invoices): render statutory PDF notices in the document language

An English invoice PDF printed "Omsättning utanför EU, ML 10 kap." and
"Godkänd för F-skatt" in Swedish, and the notice boxes below the totals
(proforma, VAT notice, notes) each had their own colour, border and
spacing, so the stack looked patchy.

The export notice is stamped in Swedish on invoices.reverse_charge_text at
create time and stored as a snapshot; the PDF printed it verbatim. The
template now matches the stored text against the shared EXPORT_NOTICE_SV
constant from vat-rules.ts and renders it from LABELS in the document
language, so already-created invoices are fixed as well. Custom or unknown
text is printed exactly as stored. The English footer reads "Approved for
F-tax (Godkänd för F-skatt)": SFL 10 kap. 12 § requires the approval to be
stated but prescribes no language, and Peppol SE-R-005 is satisfied by the
UBL file, which is unchanged.

All notices share one noticeBox style: same border, radius, padding and
spacing in a neutral palette.

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

* docs(invoices): cite the skill for the F-tax wording and add docstrings

Review pass on PR #2321: the Swedish compliance review flagged that the
F-skatt comment asserted an SFL paragraph without a skill citation, so the
comment and the DECISIONS.md line now rest on what the
swedish-invoice-compliance skill states (no language requirement for
invoice text in ML) and on the literal Swedish phrase staying on the PDF.
CodeRabbit's docstring check wanted JSDoc on localizeVatNotice and the
test helpers.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:32:48 +02:00
Jakob Wennberg cbe5580886 feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)
An out-of-pocket purchase differs from any other receipt only in the
credit account, so the Underlag pane now asks one question for an
unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu)
and books a privately paid receipt in place through POST
/api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and
the deep link into the two-step wizard. The verifikat editor stays
reachable below as the escape hatch (BFL 5 kap 6-7 §).

The person owed surfaces in Att göra under a new Betala band, one row per
person (lib/worklist expense_payout, counted in the total and exposed to
agents through the attention resource). The Utlägg nav row is gated on
existing claims, the same hybrid gate as Körjournal, since the entry
point for a new utlägg is now the Underlag pane.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:57:14 +02:00
Jakob Wennberg bff44e5757 fix(parties): one "från SCB" note per contact section (#2316)
* fix(parties): one "från SCB" note per contact section, not one under every field

The founder read four tags in a row. Kontaktuppgifter now ends with one
sentence naming the fields the register gave: "E-post, Telefon, adress
och Momsnr från SCB." Nothing else changes; the fill rule and the
equality test behind it are the same.

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

* fix(parties): the customer page's registry note tolerates the loading state

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

* fix(parties): the supplier page's registry note tolerates the loading state

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:53:13 +02:00
Jakob Wennberg 971952fe19 fix(mail): request gmail.readonly alone, mailbox address via Gmail profile (Google verification) (#2301)
* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile

Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a
"scope discrepancy": the authorization URL asked for `openid email` on top of
gmail.readonly, while the Cloud Console declares gmail.readonly only, and the
review string-matches the two. The extra scopes existed solely to learn the
mailbox address from the id_token. Gmail's users.getProfile returns that
address under gmail.readonly, so the consent request now carries exactly one
scope and the callback reads the address from the profile.

Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's
reviewers log in with credentials we hand them and treat a second factor as an
"authentication blocker"; app_metadata is service-role only, so this is an
operator switch for demo accounts, never a user-reachable setting.

Tests: scope pinned in google-oauth.test.ts, profile read in
gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in
mfa.test.ts.

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

* fix(auth): time-box the reviewer MFA exemption instead of a boolean flag

Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt
relied on someone remembering to clear it. The exemption is now
app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies
in the future, so a forgotten flag dies on its own. Anything malformed or
non-string enforces MFA. Still service-role only, still meant for the one
demo account Google's OAuth reviewers log in with.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:42:21 +02:00
Jakob Wennberg 0ad83b8d71 feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party

Founder feedback on the first Företagsuppgifter (2026-09-05): the org
number twice, the VAT number twice, the legal name repeating the
heading, and Kontaktuppgifter showing dashes while the block above had
the phone, e-mail and address from SCB.

- After a fetch the register's contact details land on the supplier and
  customer rows that point at the party: an empty field, or one still
  carrying what the register said last time, takes the new value; a
  value a person typed stays. Shown as "från SCB" on the row (by
  equality with the registry fact, no source column).
- Företagsuppgifter becomes one status line (legal form, active or not,
  registrations, a Bolagsverket warning when there is one), industry,
  seat with registration date, and size. Identity stays in the header
  (org number now formatted) and Kontaktuppgifter. The legal name shows
  only when it differs from the row's name.
- lib/parties/registry-summary.ts reads the coded SCB facts once for the
  page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape.
- v1: party_id on supplier and customer list rows and detail;
  ?expand=party on detail embeds identity, the register summary, what
  the ledger has seen and payment identities. MCP: party_id on
  gnubok_list_suppliers/customers rows and gnubok_get_party (by party,
  supplier or customer id). Read-only; the parties resource follows.

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

* chore(parties): regenerate the API skill for the party expansion; tighten the get_party description

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

* chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:33:20 +02:00
Mattsson f33628f005 feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along

The preview scored the file's accounts against the BAS reference and said
"matchas mot din kontoplan", so a consultant with a 41-account seeded company
read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap
with a non-empty period was only refused after the mapping step.

- Parse route adds preview.chart (accounts new to THIS company vs already
  present, with a sample) via planChartChanges, and preview.fiscalYear from
  precheckFiscalPeriod: the containment/overlap verdict extracted out of
  ensureFiscalPeriod, which now consumes it, so preview and import cannot drift.
- Preview card renamed to Kontoplan with the counts and the fiscal-year
  verdict (match / create / conflict with the import's own refusal text).
- Review step lists the chart among "Vad händer när du importerar?".
- executeSIEImport reports accountsCreated from the account sync; the result
  grid gets a Konton skapade card.

No import logic changed; no migration.

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

* fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton"

Skeptic pass on #2307 refuted the first cut twice:

- "Skapas vid import" was shown for a #RAR the import then refuses under
  BFL 3 kap. (19 months, non-month-end finish, mid-month start after an
  earlier year). The shape rules move into precheckFiscalPeriod as a fourth
  verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a
  consumer of one verdict, same query order.
- The Kontoplan card counted unmapped sources under "Läggs till" and kept
  listing them after the create button, while the result said 0 created.
  planChartChanges (now client-safe in lib/import/chart-plan.ts) counts
  mapped targets only; the create button moves those accounts from
  "Ej mappade" to "Finns redan" in place.
- The review line claimed existing accounts keep their name unless you opt
  in; the switch defaults to on. Reworded to match.
- Sample names follow the file for identity mappings, as the sync does.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:56:54 +02:00
Jakob Wennberg 04898c3178 feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)
Founder test on a real company (2026-09-05): a supplier created from
"Webhallen Oktober · Dataskärmar till kontoret" kept that text as its
name, and the SCB facts fetched for the party were nowhere on the
supplier page.

- Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name,
  org number, VAT number, country, then the SCB facts under one source
  line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one
  action. The registry helpers move out of the dossier into
  RegistryFacts so the three surfaces share them.
- The enrich route makes the registry's legal name the displayed name
  of the party and of supplier and customer rows that still carry the
  party's old name; all-capitals names are set in title case
  (lib/parties/registry-name.ts). Names a person set stay.
- legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps
  its vouchers, so a rebuild attaches the new key instead of offering
  the same company again.
- GET /api/parties/[id] reports whether SCB is configured.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:43:34 +02:00
Joakim Hansson 397a3b9bca feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
2026-09-05 13:36:51 +02:00
Jakob Wennberg 287828a850 fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300)
* fix(payments): refuse to book a bank row that unlinked vouchers already explain

A bank feed can deliver several affarshandelser as one row (a Bankgirot
daily aggregate: two customers' invoices, one "BGGIRERING" row with no
payer). When each invoice was already marked paid by hand, nothing on the
account equals the row, the 1:1 duplicate check passes, and "Dela
betalning" books the money a second time against whatever open invoices
the user picks (the next period's identical ones, in the reported case).

- lib/reconciliation/covering-set.ts: exact ore subset sum over a capped
  candidate list, smallest set first, closest in date second.
- detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs
  on the row's settlement account, in the row's direction, within 7 days,
  add up exactly to the row; linked through any of the three anchors drops
  a voucher, a payment row without a bank transaction keeps it.
- POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the
  set; force=true must echo expected_journal_entry_ids (same binding as the
  single door). Fails open on a detection error.
- GET duplicate-payment-check returns candidate_set next to candidate.
- MatchAllocationDialog: pre-flight panel with the vouchers, one click
  links the row to them through the existing 1:1 or 1:N bank link (no new
  voucher), "Bokfor anda" acknowledges the set; confirm is disabled until
  then. Invoices dated after the bank row get a hint badge.
- Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of
  other open invoices, 7 days, kronor) when the name sweeps found nothing;
  PaymentBookingDialog shows the covered invoice numbers and points to the
  split under Transaktioner.

Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging
guard), #2299 (supplier-side text guard).

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

* test(invoices): account for the aggregate sweep in the mark-paid route queue

The sweep issues one more transactions query whenever the name probes come
back empty, so every queued-mock sequence that reaches it gains a slot. The
sweep itself now fails open on odd client shapes (a single object for a
list query) and on errors: an advisory guard must never block "Markera som
betald".

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

* fix(payments): fail open on resolved query errors; aggregate sweep without a payer name

Review follow-ups on #2300. A PostgREST failure resolves with { data: null,
error } instead of throwing, so the set detector read a failed link lookup
as "no links" and a failed cash-account lookup as "scan every 19xx
account"; both now return null (the booking RPC keeps the last word). The
aggregate sweep never needed a customer name (a Bankgirot row names
nobody), so a nameless invoice goes straight to it instead of skipping the
guard. The already-booked panel is announced as a live region, and the
"also covers" string is plural-aware.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:59:59 +02:00
Mattsson 9418de585f fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)
* fix(invoices): say what is missing when an invoice preview cannot be rendered

The PDF route already refuses with a structured envelope that names exactly
what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a
SEK invoice; no IBAN account for a foreign currency) and where to add it.
Two clients threw that away:

- The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa
  faktura) wrapped the envelope's inner object in new Error(), which
  stringified it to "[object Object]" and left only the generic "Kunde inte
  hantera fakturan. Försök igen." fallback. The parsed body now goes to the
  error mapper whole, with the invoice context and status.

- The invoice page's Förhandsgranska navigated a new tab straight to the
  re-render URL, so a 400 showed the raw JSON in that tab. The tab is now
  opened blank inside the click's activation window, the PDF is fetched
  first, and the tab gets the PDF as a blob URL or is closed again with the
  refusal in a toast. The archived delivery copy keeps the direct open.
  Ladda ner on the same page had a fixed "Kunde inte generera PDF" for
  re-render refusals and now maps the body the same way.

Regression test on the mapper covers the exact call shape the two surfaces
use and pins the old mangled shape as the fallback it produced.

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

* fix(invoices): probe the PDF route before opening the preview tab

Resolves the review findings on the first push in one pass.

Skeptic (correctness): the archived-copy branch still called window.open
with 'noopener', which returns null by spec even on success, so every
successful archived preview also fired the "popup blocked" toast (#1613
had the same defect). Both branches now go through openDeferredTab, which
opens with a real handle and severs the opener itself.

Skeptic (regression): serving the re-render as a blob URL lost the
Content-Disposition filename and gave the tab an address that dies on
reload. The route gains ?probe=1, which runs every refusal check and
answers 204 without rendering; the page probes first, shows a refusal as a
toast, and otherwise points the tab at the real inline URL. Filename,
reload and the single render are all kept. The blob URL is gone, which
also settles the compliance swarm's noopener and unrevoked-blob notes and
CodeRabbit's revoke request.

CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled
route cannot leave a blank tab open, and the network-error mapper now
receives the active locale and invoice context.

Tests: route probe (204 without render, same 400 envelope as the render,
unknown value ignored) and the URL helper's probe flag.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:55:45 +02:00
Jakob Wennberg 34bf5a7387 fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers (#2304)
* fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers

Three shapes seen on live Profilio payloads after #2302's rows-versus-header
check went in:

- Freight and AdministrationFee live on the invoice header, not in
  InvoiceRows, while Total and TotalVAT include them. The rows summed to
  less than the header by exactly the charge and the check refused the
  invoice (14 of Profilio's 384). They are now rows: FreightVAT and
  AdministrationFeeVAT are VAT amounts (88 and 22 on a 25 % invoice), and
  the charge is gross when VATIncluded is true (99 = 79.20 + 19.80).
- Free-text rows (DeliveredQuantity "0", Total 0, VAT 0) counted as a
  stated 0 % rate beside the 25 % rows, so the migration marked the invoice
  mixed and nulled its header rate on roughly half of two registers. They
  no longer state a rate, and land as line_type 'text' with no amounts, the
  way the invoice page and the booking engine expect them.
- DeliveredQuantity is serialised as a string and was stored unparsed.

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

* fix(migration): type the text-row check so resolveInvoiceVat's line shape accepts it

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:41:17 +02:00
Jakob Wennberg e80ea74e76 fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)
The first production run of the row-completion pass (#2291) wrote 345
Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT
computed on top, beside a header (Net / TotalVAT) that was right. Fortnox
prices an invoice either excluding or including VAT and says which with
the invoice-level VATIncluded flag; the mapper had always read the row
Total and Price as net. Every such row set is 1.25 x its header net, to
the öre, across all 345.

- lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price
  by (1 + rate) when VATIncluded is true; TotalExcludingVAT and
  PriceExcludingVAT are preferred when the payload carries them. A row
  without a rate cannot be split and keeps its amount.
- complete-invoice-lines.ts: rows whose net or VAT disagree with the header
  the same payload established by more than 1 kr are reported as
  rowsMismatch and left untouched. Öresavrundning stays inside the
  tolerance; VAT-inside rows, header-level freight and discounts do not.
  Rows that contradict their own header are worse than no rows.
- Cron summary carries rowsMismatch.

None of the 345 is open or booked; a separate repair removes today's rows
for them so the fixed pass refills them.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:53:48 +02:00
Mattsson 7c36d471b5 fix(sandbox): use posting engine and recover failed seeds (#2297)
* fix(sandbox): seed through posting engine and recover failed attempts

* test(sandbox): align CI auth schema for anonymous users
2026-09-05 10:42:14 +02:00
Jakob Wennberg 8e1f9d5201 fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach (#2291)
* fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach

The migration maps sales invoices from the provider's list payload and
hydrates the detail form (rows, net, VAT) inside a fixed 90 s budget, open
invoices first. Fortnox, Briox and Björn Lundén ship no rows in a list
response, so every invoice the budget did not reach was imported as a header
with a total and no invoice_items, and nothing ever came back for it: the
wizard never showed the hydration report, so the user found out on the
invoice page. Measured on prod today: Profilio 384 of 384 (migrated before
hydration existed), Loftux 311 of 672, Damac 182 of 542, Clearstoq 1 125 of
1 125.

- lib/providers: hydrateSalesInvoices() hydrates a caller-chosen subset of
  an already-listed register, so a follow-up can spend its budget on the
  invoices still incomplete on our side instead of re-walking the register
  open-first and never reaching the rest.
- arcim-migration: completeMigratedInvoiceLines() starts from OUR row-less
  non-draft invoices, joins them to the provider register on number + date
  (unique on both sides), hydrates only that subset and writes each
  invoice's rows once the detail total matches the stored total to the öre.
  The header VAT split is rewritten only when the stored one holds no
  evidence (null rate, or a non-zero rate label beside 0 kr VAT and
  subtotal = total). Never the total, status, payments or a journal entry.
- Hourly cron (/api/extensions/arcim-migration/complete-invoice-lines/cron,
  vercel.json + Docker crontabs) drives the pass over consents accepted in
  the last 60 days, newest first, with a per-company share of the run.
- The wizard's result screen now shows "x av y fakturor hämtade med rader"
  and that the rest are fetched in the background within the hour.

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

* fix(migration): write the header VAT fill as a literal, raise the schema-guard ceiling for the row inserts

The phantom-column scanner resolves only object-literal payloads. The header
update is now a literal (so its six columns are checked); the two
invoice_items inserts are runtime row arrays from mapSalesInvoiceLine, the
same shape the orchestrator already inserts, so the ceiling moves 399 to
401 with the reason recorded beside the earlier ones.

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

* fix(migration): gate the completion cron on token freshness, not consent age, and visit every usable consent

Two review findings held. Prod holds 57 accepted consents from the last 60
days, so a fixed page of the newest 25 would leave older companies with
row-less invoices waiting behind companies that are already done: the cap
is gone (a company with nothing left costs one query and no provider call).
And the consent's created_at said nothing about whether its credentials
still work: Fortnox refresh tokens live 45 days and rotate on every
refresh, so eligibility is now read off the token row (access token expired
within the last 45 days, or no expiry at all), which also stops a dead
consent from being retried every hour.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:20:10 +02:00
Jakob Wennberg 0bd3c27fba fix(assistant): the salary fact follows the ledger, never the column default (#2290)
* fix(assistant): the salary fact follows the ledger, never the column default

The in-app assistant told a payroll-running aktiebolag in every answer that
it "betalar inte löner" (support case 2026-09-04). company_settings.
pays_salaries is NOT NULL DEFAULT false and only the Skatt settings form
writes it, so for every company that never opened that form the flag reads
false whatever the ledger says, and lib/agent/ask/snapshot.ts asserted that
default as a fact.

- Trigger salary_runs_booked_marks_employer (20260904191000): a booked
  salary run sets pays_salaries = true and fills a never-attested
  employer_registered, at the one place every writer (dashboard, MCP, v1,
  seeders) passes through. Backfill for the 12 companies on prod already
  booking payroll with the flag at its default (8 of them also lacked the
  employer flag, and with it their AGI deadline reminders). An explicit
  employer_registered = false stays the user's answer.
- The assistant snapshot applies the composer's employee-facts doctrine:
  positive evidence (active employees, the flag, an attested employer
  registration) yields the fact, only an attested negative yields the
  negative, the default yields nothing. It also names Inställningar >
  Skatt / > Bokföring so the model can point at the page.
- The composer's KÄNDA FAKTA no longer prints "Betalar ut lön: nej" from
  the same default.

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

* fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion

A NULL source_company_id in the subquery would make NOT IN never true and
silently skip the whole backfill.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 09:49:21 +02:00
Jakob Wennberg e684606b23 fix(supplier-invoices): a credit note is never a payable, so it never waits for attest (#2289)
* fix(supplier-invoices): a credit note is never a payable, so it never waits for attest

A supplier credit note created with Kreditera was inserted at 'registered',
the attest entry state, while the detail page (rightly) offered no attest
for it. The worklist counted every 'registered' row, so Att göra showed
"1 leverantörsfaktura att attestera" that nobody could clear (support case
2026-09-04; 14 such rows on prod plus one MCP-approved credit note).

- One row builder (lib/supplier-invoices/credit-note.ts) for the dashboard
  route, the MCP executor and the v1 API: the credit note rests at
  'credited' from birth, the status the provider importers already use.
- CHECK supplier_invoices_credit_note_not_payable keeps every writer out of
  the payable states; migration backfills the stuck rows (one immutable
  reset-source row skipped, hence NOT VALID).
- The worklist attest count excludes credit notes explicitly.
- GET /api/supplier-invoices/[id] hydrates credited_original with a second
  scoped query: PostgREST cannot pick a direction for a self-referencing
  embed hint and returned the one-to-many side (an empty array), which the
  page rendered as "Krediterar: Ankomst #" with no number.

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

* test(supplier-invoices): type the GET route response in the credited_original tests

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

* fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion

A NULL source_company_id in the subquery would make NOT IN never true and
silently skip the whole backfill.

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

* test(schema): raise the unresolved-payload ceiling by 3 for the credit-note row builder

The three credit-note creation paths now insert the row from one builder,
so the scanner sees three dynamic payloads instead of three literals. The
columns are the literal in lib/supplier-invoices/credit-note.ts, pinned by
its test; the resting status is additionally held by the DB CHECK.

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

* test(pg): credit-note fixtures in the overdue-cron tests rest at credited

The two fixtures that seeded a credit note on 'registered'/'overdue' now
violate supplier_invoices_credit_note_not_payable. The cron test seeds the
credit note the way the routes create it since 20260904190000; the 20260607
backfill case asserts the scenario it repaired is now unreachable.

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

* test(pg): ledger-usage-stats seeds its credit note at credited

The fixture defaulted every row to 'registered', which the CHECK
supplier_invoices_credit_note_not_payable now refuses for a credit note.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 09:43:00 +02:00
Jakob Wennberg a870c7f03e fix(import): attach underlag by the basename of a folder-picked upload (#2288)
A folder-picked Fortnox export failed 50 of 50 attaches with
UNDERLAG_REF_MISMATCH although the preview had matched every file. The
preview is built from File.name, a bare filename by spec, while the attach
route read the multipart filename, which Chrome fills with the folder-relative
path for folder selections (2026/06/Leverantorsfakturor/A166_x.pdf). The
guard that requires a file to land where the preview said compared the
previewed basename with a path the parser cannot read, and refused.

The route now reduces the multipart filename to its basename once, at the
boundary, before the resolver check and before archiving, so the archived
file_name is the name the user reviewed rather than a path. The parser keeps
its no-directory-stripping rule: the manual-reference box shares it, and a
typed 2024/01/31 there is a date, not voucher 31. Both separators are
stripped; nothing else is normalized.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:14:33 +02:00
Jakob Wennberg 9618bab273 fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.

1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
   seeds the next forward year and kept that name when the user re-dated the
   form. The name now follows the typed dates until the user edits the name
   (fiscalYearName exported from suggest-fiscal-period).

2. Nollställ of the backfilled year was refused with next_year_dependency
   because 2024 carried an opening-balance verifikat. Any IB in the next year
   counted as reliance, so a backfilled year could never be reset, while a
   next year WITHOUT an IB (whose balansrapport really rolls from this year)
   was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
   the block fires only when the next year is locked, closed or has its own
   closing entry; a bokslut-generated IB is still refused via this year's
   closing_entry_id (year_end_state). The snapshot returns next_period
   {id, name, has_opening_balances} and the dialog states that the following
   year's IB stays as it is.

pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:49:23 +02:00
Jakob Wennberg db289e3bdc fix(payments): lock supplier payment batch inserts to the RPC and log the raw error behind create_failed (#2282)
* fix(payments): lock supplier payment batch inserts to the RPC and log the raw error behind create_failed

Two residuals from PR #1989 (atomic create_supplier_payment_batch RPC).

Root cause 1: the original table migration (20260810160748) left member
INSERT policies on supplier_payment_batches and supplier_payment_batch_items.
The RPC is SECURITY DEFINER and never consulted them, so their only effect
was to let any company member insert straight through PostgREST (browser
devtools, a raw JWT call) and skip the RPC's invoice locking, in-transaction
active-batch recheck and header/items totals consistency. The single write
path existed in code only, not in the database.

Fix 1: new migration 20260904121000 drops
  "insert own-company supplier_payment_batches" and
  "insert own-company supplier_payment_batch_items".
SELECT policies on both tables and the UPDATE policy on batches (the cancel
route) are untouched. No application code inserts into either table.

Root cause 2: createSupplierPaymentBatch discarded the RPC error object and
returned a bare create_failed, so the tenant guard (42501), a constraint
violation inside the SECURITY DEFINER body and a PostgREST schema-cache miss
after a deploy (PGRST202) were indistinguishable from each other and from an
empty payload or an unmapped refusal code.

Fix 2: log the raw error (code, message, details, hint) plus companyId,
batchId and item count through lib/logger before each of the three
create_failed returns. The client-facing result is unchanged; debtor_snapshot
and the item rows (IBAN, payee data) are never logged.

Tests: pg-real asserts the exact remaining policy set, that a member's and the
owner's direct INSERT into either table is refused by RLS (42501), and that the
same member still creates through the RPC and cancels through UPDATE. Unit
tests assert the logger receives the raw error fields and that create_failed
is still returned.

Fixes #2060

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

* docs(decisions): carry the ten-issue batch decision lines in one PR

Append the decision lines for PRs #2272 through #2282 here so the other
nine PRs in the batch do not touch DECISIONS.md and stay mergeable in
any order (the union merge driver is ignored by GitHub).

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

* fix(payments): redact and bound raw RPC error text before logging

Addresses the Superagent P2 on PR #2282 (lib/payments/batch-service.ts):
message, details and hint from Postgres/PostgREST were logged verbatim, and
Postgres quotes the entire failing row in details on CHECK and NOT NULL
violations ("Failing row contains (..., SE45..., Anna Andersson, ...)"), so
payee and account data could reach the log line. Excluding debtor_snapshot
and the item rows did not cover the error text itself.

Fix: a call-site helper, boundedRedactedText, runs each of the three text
fields through lib/observability/redact.ts redactString (SE IBANs,
personnummer, emails, API keys), drops any "Failing row contains (...)"
payload whole (no pattern catches a payee name), and bounds the result to
500 chars, redaction before bounding so a cut IBAN cannot leave a digit
fragment behind. The SQLSTATE code stays verbatim; the client-facing
create_failed result is unchanged.

Test: rejected RPC error carrying an IBAN in message, the full failing row
(IBAN, payee name, account) in details and an oversized hint with the IBAN
straddling the bound; asserts the serialized log context contains none of
them, the row payload is replaced, and the hint is <= 500 chars ending in
[TRUNCATED]. DECISIONS.md line for #2060 updated accordingly.

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

* fix(payments): drop the dotAll regex flag, tsconfig targets ES2017

The failing-row pattern used the `s` flag, which TypeScript rejects
below es2018 (TS1501) and broke Build (zero extensions). `[\s\S]*`
matches across newlines on every target.

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

* fix(payments): log code and message only for a failed batch RPC

Reworks the logging half of #2060 from first principles. The diagnostic
value of a failed create_supplier_payment_batch call lies in the SQLSTATE
code and the message: the RPC's own RAISE text, "violates check constraint
<name>", "duplicate key value violates unique constraint <name>". details
is exactly where Postgres puts row data ("Failing row contains (...)",
"Key (...)=(...)") and hint adds nothing operational, so neither is logged
at all.

That removes the payee/account exposure Superagent flagged on #2282 without
the bespoke redact-and-bound helper, its regex and the TS-target workaround
it needed: boundedRedactedText, FAILING_ROW_PATTERN, RPC_ERROR_TEXT_MAX and
TRUNCATED are deleted, and the redact import goes with them. The logger's
own redaction stays as the safety net for message. Client-facing result
unchanged (create_failed).

Test: an RPC error carrying an IBAN and a payee name in details and hint;
the serialized log context contains neither field in any shape, and
rpcError is exactly { code, message }. Exact-match and PGRST202 tests
updated to the two-field shape. DECISIONS.md line for #2060 rewritten.

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

* docs(decisions): record the first-principles rework of the ten-issue batch

Replace the decision lines for #2263, #2250, #2256 and #2211 with the
reworked shapes, add the shared customer-share definition for #2248,
and note the CLAUDE.md principle (#2283) that drove the rework.

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

* docs(decisions): note the fiscal-year selection cap on #2280

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:19:18 +02:00
Jakob Wennberg 743e3ae7cc fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277)
* fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments

The dashboard match-invoice route, its v1 twin and the pending-operation
match_transaction_invoice executor wrote invoice_payments.amount as the
cash received in invoice currency. When a whole-krona bank line settles an
öre-carrying remaining (the customer pays the rounded "Att betala"),
planInvoicePayment advances paid_amount by the remaining only and books
the öre on 3740, so the row exceeded the receivable by the absorbed öre:
remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60
paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with
negative scaled moms, the historical AR ledger showed -0.40 outstanding
on a paid invoice, and a storno of the payment voucher restored
paid_amount 0.40 off (issue #2250).

PR #2236 defined the amount for the manual, MCP and Stripe paths as the
amount APPLIED to the invoice (new paid_amount minus the prior one). The
three bank-match paths now share that definition through one helper,
appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which
recordInvoicePaymentRow() uses as well. Every other field of the row
(payment date, currency, exchange rate, journal entry, bank transaction,
notes) is unchanged. Without a residual the applied amount equals the
cash received, so ordinary matches post identical rows; cross-currency
rows are now öre-rounded like paid_amount instead of the 4-decimal spot
conversion, so row and paid_amount agree.

Existing rows carrying the overshoot are not repaired here; that is a
separate call.

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

* refactor(invoices): one writer for invoice_payments rows

Rework of the #2250 fix from first principles. The bank-match paths did
not just get the amount wrong; the class of bug is that invoice_payments
rows were hand-built at five product sites (dashboard bank match, its v1
twin, the pending-operation match, the link-to-existing-voucher flow, and
the #2236 paths through the helper), each computing its own fields with
no single definition of what the row means.

recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the
one writer. Its options grew by what the bank paths set, all optional with
today's defaults so the #2236 callers are unchanged: transactionId
(default null), exchangeRate (the rate actually used; omitted =
invoice.exchange_rate, explicit null stored as null) and notes (default
null). The failure result carries the Postgres SQLSTATE so the routes keep
mapping a unique violation (23505) exactly as before. The applied-amount
formula is an internal detail of that file again.

Routed through the writer: app/api/transactions/[id]/match-invoice, the
v1 match-invoice twin, commitMatchTransactionInvoice in
lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts
(strict plan, same currency only: its amount is unchanged, it now shares
the row semantics). The pending-operation path used to drop the insert
error on the floor; it stays non-fatal but is logged with ids.

Guard: scripts/checks/no-new-antipatterns.mjs gains
direct-invoice-payment-insert, a file-set rule with no baseline (0 today):
.from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or
extensions/ outside lib/invoices/invoice-payment-row.ts fails
npm run check:guards. Operator scripts under scripts/ are out of its scope
on purpose.

Tests: the writer's unit tests cover the new options, the explicit-null
rate, the SQLSTATE passthrough and the öre-rounded prior-paid
subtraction; the per-path 3740 tests from the first commit stand; mock
insert slots now return the row id the writer selects back.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:13:29 +02:00
Jakob Wennberg 2d927349d3 fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint (#2279)
* fix(payroll): enforce the jamkning both-dates invariant with a database trigger

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code. Two writes could still store the inert shape the
engine never applies: (1) concurrent PATCHes, where both handlers
validate a fetched snapshot and then issue an unconditional partial
update, so a { jamkning_valid_to: null } that committed last left a
percentage without an end date; (2) direct SQL and service-role writes,
which bypass the validator entirely.

Fix: migration 20260904120000 adds trg_enforce_employee_jamkning_dates,
BEFORE INSERT OR UPDATE OF jamkning_percentage, jamkning_valid_from,
jamkning_valid_to ON employees. It mirrors validateJamkning: a non-null
percentage needs both dates, and valid_to may not precede valid_from.
On INSERT it always checks; on UPDATE it checks only when one of the
three columns actually changes (IS DISTINCT FROM on OLD vs NEW), so a
legacy incomplete row stored before #2240 stays editable in unrelated
ways, including by a route that writes the whole row back. The error is
SQLSTATE 23514 with the stable prefix "JAMKNING_INCOMPLETE: " followed by
the same Swedish sentence the validator produces. The function is
SECURITY INVOKER with search_path pinned. No backfill: existing
incomplete rows are listed by scripts/list-incomplete-jamkning.ts and
decided per company.

App side, jamkningIssueFromDbError in lib/salary/jamkning-rules.ts
recognises the trigger rejection, and the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor) answer it with the same
400 / VALIDATION_ERROR and sentence as the merged-state check, instead
of a generic 500 / INTERNAL_ERROR.

Tests: tests/pg/employees-jamkning-trigger.pg.test.ts (25 cases against
real Postgres, including the interleaved two-transaction race), unit
tests for the helper, and one race test per update path.

Fixes #2256

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

* fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code, across many writers. Two concurrent PATCHes that each
validated a fetched snapshot and then wrote unconditionally could leave
a percentage without an end date (the engine never applies such a
beslut, so the payslip and AGI silently carry the table tax), and
direct SQL or service-role writes never saw the validator at all.

Fix, from first principles: the invariant is a row-level fact, so it is
declared as a row-level CHECK constraint, employees_jamkning_dates_check
(migration 20260904120000), added NOT VALID so the migration cannot
fail on production because of rows stored incomplete before #2240.
From now on every INSERT and every UPDATE of any row is checked. This
replaces the trigger the issue proposed: no plpgsql function, no
per-column change detection, no custom message convention, and the
rule is visible in the schema.

One behavioural difference from the proposal: a legacy incomplete row
is refused on its next edit, related or not, until the beslut is
completed (both dates) or cleared (percentage null). The application
maps that rejection (SQLSTATE 23514 naming the constraint) to the
validator's own Swedish sentence in the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor), so the user is told
exactly what to complete; a rejection the merged row cannot explain
(a concurrent change) gets an umbrella sentence. No backfill: those
rows are listed by scripts/list-incomplete-jamkning.ts and decided per
company.

Tests: tests/pg/employees-jamkning-check.pg.test.ts against real
Postgres (constraint shape, INSERT and UPDATE rejections and
acceptances, the interleaved two-transaction race, the legacy
consequence), unit tests for the mapping, and race plus legacy tests
per update path. The PostgREST error shape was verified against a real
PostgREST: the constraint name is in `message`, `details` carries the
failing row and is never forwarded.

Fixes #2256

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:01:34 +02:00
Jakob Wennberg c6ca119e73 feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)
* fix(parties): one suggestion per legal person, and a later run may rename an untouched one

Found while walking the queue end to end: two voucher keys naming the same
company ("TIC identity · … The Intelligence Company AB (publ)" and
"Utbetalning leverantörsfaktura …, The Intelligence Company AB (publ)")
became two suggestions and, after Lägg upp, two suppliers; and a suggestion
made before the legal-form anchoring kept its sentence-long name for good,
because apply_party_suggestions never touched a name.

- Suggestions whose display name is anchored on a legal form read out of
  the voucher text (name_anchored) are grouped: one item, both keys as
  aliases, stats summed. Such a name also attaches to an existing party
  called exactly that, legal form included, unless an org number on either
  side says otherwise. Registered company names are unique in Sweden; a
  bank memo never groups or attaches by name.
- Migration 20260904030000: apply_party_suggestions renames a suggestion
  nobody has touched (no decision, no user or registry fact) to an anchored
  name from a later run, and reports 'renamed'. Confirmed and decided
  parties keep their names.

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

* fix(parties): read legal_name for exact-name attach; say a row is foreign instead of offering SCB

next build: ExistingParty had no legal_name, so the exact-legal-name index
did not compile. The query now selects it.

Queue rows whose voucher text places the company abroad show
"Utländskt bolag (Nederländerna), finns inte i SCB" instead of a search
that cannot succeed; the promote dialog counts them separately from rows
that merely lack an org number; the dossier shows the country.

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

* fix(parties): carry country on the dossier row

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

* feat(parties): one review list for SCB matches, a model reading for bank memos, refresh demoted

- Review list ("Hitta org.nr (n)" in the queue toolbar): every suggestion
  SCB could hold but that lacks an org number is asked for, one row at a
  time under SCB's rate limit; rows with exactly one active match are
  shown ticked and approved in one click, the rest keep the per-row
  picker. Nothing is written before the click.
- Model reading (lib/parties/ai-name.ts, through getAiService): when the
  rules find no legal form or country in the texts, one call reads the
  counterpart out of the bank memo; kept as a 'model' fact, shown as
  "Läst ur verifikatet", used as the query, never as a hard key. On
  demand only, never when the queue builds.
- "Uppdatera förslag" moves from the page header to a ghost button in the
  toolbar: the queue builds itself now.

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

* fix(parties): review list passes the dialog overflow guard; plural for match counts

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

* fix(parties): gate the model reading on the company's AI capability

Same gate as every other model call on company data: the capability the
company holds by plan and can switch off. No call, no fact, no reading
without it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:01:10 +02:00
Jakob Wennberg be0478c219 fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513 (#2278)
* fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513

Root cause: collectKontantmetodCutoff compared invoice_payments against
the gross invoice total and never read deduction_total. Under
fakturamodellen the customer owes total minus the skattereduktion; the
deduction is a fordran on Skatteverket carried on 1513 by the payment
voucher (Dr 1930 customer share / Dr 1513 deduction / Cr 30xx / Cr 26xx
in full), and every settlement path records the customer share as the
payment row amount. A fully paid ROT/RUT invoice therefore showed exactly
deduction_total as outstanding, and the year-end cut-off booked a phantom
Dr 1510 / Cr 30xx / Cr 2618 on top of a sale whose revenue and moms were
already fully recognised: revenue overstated and vilande moms invented.

Fix: the customer's outstanding is total - deduction_total - paid (the
deduction follows the sign of the total, since credit notes store it as
a positive magnitude), floored at zero on the invoice's own side for
over-collection noise, mirroring the invoices_remaining_amount_guard
formula. The moms carried into the cut-off is scaled by the customer
share, not the gross total, so an unpaid ROT/RUT invoice reports its
whole moms in the final period and a part-paid one the matching
fraction. Invoices without a deduction take the unchanged gross path.

The readiness gate, the pending-operation executor and the MCP tool all
consume this collector, so they inherit the fix. The Skatteverket share
itself (an unpaid ROT/RUT invoice's 1513 fordran at year end) is not
part of the cut-off and stays a separate change, as is the 1513 point
already deferred on the currency revaluation.

Fixes #2248

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

* refactor(invoices): one definition of the customer share of an invoice

The customer share of an invoice (total minus the ROT/RUT deduction,
and what remains of it after payments) was re-derived by hand at three
TypeScript sites plus the SQL guard invoices_remaining_amount_guard, and
the kontantmetod cut-off's copy had drifted to the gross total (#2248).
Fixing the cut-off's arithmetic alone would leave the next copy free to
drift the same way.

lib/invoices/customer-share.ts now holds the definition:
invoiceCustomerShare(invoice) returns total minus sign(total) times
deduction_total (the deduction is stored as a positive magnitude, also
on credit notes), and invoiceCustomerOutstanding(invoice, paid) returns
the signed residual after payments. The module comment names the SQL
twin (migration 20260817191708) so the two stay in lockstep; the SQL is
untouched.

Call sites moved onto the helper with byte-identical behaviour:
kontantmetod-cutoff.ts (keeps its as-of payment sum, the sign-aware zero
floor for ROT/RUT rows and the moms scaling), rot-rut-file.ts (the
customer-share-paid test) and payment-sync.ts (the storno path, which
still floors with Math.max(0, ...) because it persists the column).
Plain invoices get their total back exactly as stored, so their paths
do not change by a bit. New unit tests cover plain, ROT/RUT, credit-note
sign, null deduction and the lockstep with the guard's formula.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:01:06 +02:00
Joakim Hansson 304b50b5eb feat(invoices): grouped sections and a grouping picker on the customer invoice list (#2101)
* feat(invoices): grouped sections and a grouping picker on the customer invoice list

Default view groups rows into Utkast / Väntar på betalning / Betalda
och avslutade sections; a toolbar picker switches grouping to customer,
month, or none, written back to the URL as ?group= so views stay
shareable. Column sorting applies within each section and cycles
asc / desc / default so an applied sort can be released; paging and the
detail pager follow the rendered group order. Both message catalogs
carry the new strings.

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

* fix: group by customer_id, not display name (review)

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

* fix(invoices): address review: shared bucketing helper, flat default, no #2093 revert

- keep CHECKBOX_REVEAL_CLASS and useRangeSelect from main: the PR had
  re-inlined the old hidden-until-hover classes, reverting #2093, and the
  same region now carries #2117's shift-click range selection
- default the grouping picker to 'none': sections on the most-used list
  page are a design change, so grouping stays an explicit choice
- extract the ~90 lines of bucketing into lib/lists/group-rows.ts, a pure
  helper with vitest coverage that both list pages now render from
- derive statusGroupOf from matchesListTab so the sections and the tabs
  cannot drift apart
- replace the banned em dash placeholders with an UNKNOWN_GROUP_KEY
  sentinel and a translated 'Saknas' label
- section headers carry data-no-stagger so they skip the row animation

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

* fix(invoices): flat is the URL-less default; drop the sort cycle (review)

Picking Status stripped the group param while the initialiser falls back to the flat list, so the choice was lost on reload; now only 'none' owns the URL-less state. The header sort returns to main's two-state toggle (DECISIONS 2026-08-11).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:49 +02:00
Joakim Hansson 4eb1626129 feat(salary): recurring payroll lines per employee (#2042) (#2044)
* feat(salary): recurring payroll lines per employee (#2042)

A standing per-employee payslip row derived into every salary run inside
its validity window, e.g. a benefit-bike bruttolöneavdrag of -670 kr/month.
Mirrors the employee_benefits pattern end to end:

- employee_recurring_lines table with RLS, audit + updated_at triggers, and
  a salary_line_items.source_recurring_line_id back-link; amount sign and
  account format enforced by CHECKs
- run-calculation step 8d3 derives rows with flags computed from the item
  type (gross deductions reduce tax + AGA bases, net deductions post-tax);
  derived rows are excluded from the manual-line set like benefit rows
- CRUD routes under /api/salary/employees/[id]/recurring-lines with the
  same 401/403/404/400 contract as the benefits routes
- EmployeeRecurringLinesPanel on the employee page, sv/en strings
- registered in the BFL full-archive export

Closes #2042

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

* fix(salary): address #2044 review: feed recurring rows to the engine, guard deletes

- Derived recurring rows are now appended to the calculateSalary lineItems
  set: they were inserted into salary_line_items but excluded from the
  in-memory calculation, so a recurring deduction never affected the payslip
  math (CodeRabbit, major).
- DELETE deactivates a line that has derived rows instead of hard-deleting:
  ON DELETE SET NULL would turn a draft run's derived row into an apparent
  manual row that recalculation keeps forever; deactivation preserves the
  provenance link and lets the next recalculation drop the draft rows
  (CodeRabbit, major). The panel hides inactive lines.
- POST employee lookup uses maybeSingle and answers 500 on lookup failure,
  404 only on zero rows.
- Panel: try/finally releases loading/submitting on network failure, and a
  request sequence guard stops a stale load from overwriting a newer list.

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

* fix(migrations): move employee_recurring_lines off 20260830140000, which upstream now occupies

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

* fix(migrations): bind employee_id to company_id with a composite FK (review)

The dimensions pattern: UNIQUE (id, company_id) on employees plus a
composite FK, so RLS company scoping cannot be sidestepped by pointing
a recurring line at another company's employee (IDOR, CWE-639).

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

* fix(salary): address review: deductions only, race-free delete, engine and pg tests

Review round on #2044:

- Blocker: recurring 'other' additions removed from the whitelist, the
  migration CHECK and the panel. calculateSalary only treats
  ADDITION_TYPES as additions, so a recurring taxable addition rendered
  on the payslip without entering gross, tax, AGA or AGI. Re-add only
  together with engine support (recorded in DECISIONS.md).
- Delete race: salary_line_items.source_recurring_line_id is now NO
  ACTION instead of SET NULL; the DELETE route deletes first and falls
  back to deactivation on 23503, so a deletion racing a concurrent
  derivation can never orphan a derived row into an apparent manual row.
  NO ACTION defers to statement end, so company-deletion cascades are
  unaffected.
- Correction runs copy source_benefit_id / source_recurring_line_id, so
  recalculating a correction no longer derives the copied rows a second
  time (pre-existing for benefits, now pinned).
- Engine tests: gross_deduction_other through calculateSalary asserts
  gross, taxable income and avgifterBasis drop while the semester base
  stays; net_deduction_union only moves the paid-out net.
- pg-real tests for the new table: RLS membership, composite FK
  cross-company refusal, deduction-only CHECKs, and the NO ACTION
  back-link blocking deletes of derived-into lines.
- Nice-to-haves: POST rounds the stored amount to ore, the redundant
  single-column employees FK is dropped (composite carries the cascade),
  the schemas.ts comment references the real migration version, and the
  panel explains the validity-window semantics (payment date, bounds
  inclusive, no proration).
- Rebased onto main; the phantom-columns ceiling re-measured at 395 on
  the merged tree.
- DECISIONS.md records the vacation-basis judgment call (semester base
  not reduced by recurring gross deductions).

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

* fix(salary): gate recurring-line writes on the writer role, 404 unmatched deletes

Two findings from the 2026-09-02 review round:

- Superagent P1: the write policies were membership-only, so a read-only
  viewer could write recurring payroll deductions straight through
  PostgREST, bypassing the route's requireWrite. The table now carries
  aa_enforce_company_writer_role, the same gate 20260902093000 attaches
  to every company-scoped table (it also fires inside SECURITY DEFINER
  bodies, where RLS does not apply). The migration is re-versioned to
  20260902140000 so the function exists when a fresh database replays
  the folder in order.
- CodeRabbit: a filtered DELETE reports no error when nothing matches,
  so an unknown or cross-company line answered 200 deleted: true. The
  delete now selects the removed row and answers 404 when it is null.

Tests: pg-real asserts a viewer is refused insert, update and delete
with 42501 while the row survives unchanged, plus a non-member case; the
route tests pin the 404. 896 salary tests green, rebased on main.

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

* test(salary): pin the recurring-line payload column sets

Answers the phantom-column ceiling finding with scoped assertions rather
than a bare ceiling raise: the PATCH route test now asserts the exact
writable column set, and the comment records that the pg-real test covers
the derived-row shape against the real table. Making the PATCH payload a
literal would turn a partial update into last-write-wins, which is why
the shape stays unresolved.

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

* fix(salary): round recurring line amounts with roundOre

check:guards naive-ore-round ratchet: the derived recurring row used Math.round(x * 100) / 100 (baseline 615, +1); roundOre is already imported in run-calculation.ts.

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

* fix(migrations): guard the employees unique-key add against #2145 merge order

#2145 (expense claims) also adds employees_id_company_id_key. Wrap this
migration's ADD CONSTRAINT in an idempotent DO block so whichever of the
two PRs merges second does not fail on a duplicate constraint.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:45 +02:00
Mattsson 50b6299699 feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row

A ROT/RUT invoice is stored with remaining_amount net of the deduction, so
once the customer pays it flips to paid and drops out of the matchable set.
Skatteverket's payout for the 1513 share then lands as an income row with no
candidate: the only clearing path was a headless settle endpoint that never
linked the bank row.

The candidate is the payout request (one lump sum per begäran, possibly
covering several invoices), modelled exactly like the supplier-invoice hint:

- migration 20260904020000: transactions.potential_rot_rut_payout_request_id
- pure matcher (exact amount vs decided_total ?? requested_total, boosted
  when Skatteverket is named, ambiguous when two requests share the amount)
- hint written at bank ingest and by batch-match-invoices; cleared by the
  link and reconciliation paths and by clearSettledInvoiceSuggestions
- shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing
  settle route and the new POST /api/transactions/[id]/match-rot-rut-payout,
  which books debit 19xx / credit 1513 and links the row in one call
- transactions inbox pill, own confirm dialog listing the covered invoices,
  manual fallback section in the invoice picker, worklist and Att göra rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers

Skeptic findings on 6aa7b2e5c:
- a bank row larger than the begäran was booked in full, driving 1513 into
  a credit balance and rewriting decided_total to the bank amount: refuse
  amount > decided_total ?? requested_total in the service and block the
  dialog's confirm with the reason
- two concurrent settles could both attach and credit 1513 twice: the
  request update now locks on settlement_journal_entry_id IS NULL and the
  loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id
- a row with a stale (reversed) journal_entry_id passed the route guard but
  always lost the null-only link CAS: the route forwards the pointer it read
  and the service locks on that value, as link-journal-entry does
- the pinned underlag on the bank row now propagates onto the voucher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran

CodeRabbit findings on a93dc46b8, one batch:
- picker and dialog only offer a begäran to SEK rows (the route refuses
  other currencies, so the manual flow no longer dead-ends)
- a voucher-less `paid` request (beslut recorded via PATCH, money not yet
  booked) is matchable; settled means a settlement voucher exists
- ingest and batch-match check the hint update's error before draining the
  pool or counting the match
- the invoice.match_confirmed payload clears the payout hint like the row
- migration 20260904021000: partial unique index on journal_entries
  (company_id, source_id) for live rot_rut_payout entries, so two racing
  settles cannot both book a voucher; pg test included

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:33:41 +02:00
Jakob Wennberg 82859d01db feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)
* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones

The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.

- lib/parties/name-extract.ts: name candidates read out of the text,
  anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
  ...) and on country words, plus EU VAT numbers. Every candidate is a
  substring of the text; foreign forms and countries mark the candidate
  as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
  text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
  the voucher texts are stored as a ledger fact for the picker, the
  country is stored when the text says, and a single foreign VAT number
  in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
  cleaned head last, at most three queries, stopping at the first hit;
  no SCB call when the best reading is foreign, the response says which
  company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
  täcker bara svenska företag." with a hint to save by name and VAT
  number; alternate readings offered as one-click searches when the
  first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
  not seen, not only on a first visit; the toast only appears when
  something was created.

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

* fix(parties): take a text-derived VAT number only on the expense side

A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:43:41 +02:00
Jakob Wennberg 227a6317f1 fix(import): label the SIE preview IB total as "summa debet", not "IB Summa" (#2142)
The fourth stat card in the SIE preview showed the debit-side total of the
opening-balance voucher under the label "IB Summa". A user read it as the
net ingående balans and could not reconcile it against any single figure.

- Relabel the card "IB, summa debet" and add a one-line helper saying it is
  the sum of all debit balances in IB, not a single account balance. The
  number equals "Total debet" in the Balansräkning (IB) card right below.
- Review step: "Skapar IB-verifikation, summa debet X" instead of
  "Skapar verifikation för IB på X".
- Comment the field in generateImportPreview so the meaning is explicit.

No data or logic change: openingBalanceTotal keeps its semantics.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 09:42:41 +02:00
Mattsson 8265b5d166 feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search

After a SIE migration or verifikat backfill, customer invoices exist only
as journal entries: the invoice list, kundreskontran, /api/invoices, v1
invoices.list, and MCP list_invoices all looked complete while silently
omitting everything before the register's first invoice (user report:
two invoiced fees nearly re-invoiced as "uninvoiced").

- lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest
  register invoice; flags posted non-invoice-engine AR verifikat
  (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so
  manual/API backfills are caught too.
- Invoice list page: one attn line disclosing the boundary (sv+en).
- Kundreskontra: register_coverage in the report payload, rendered in the
  summary card and as an explanation under "Ej avstamd".
- /api/invoices GET: invoice_register_coverage in the response.
- v1 invoices.list: meta.coverage + registry pitfall documenting it.
- MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on
  the first page, pointing agents at gnubok_query_journal.
- Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross
  amounts with sv-SE formatting, alongside number/customer matching; a
  known net amount like 14 000 now finds the 17 500 kr row.

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

* fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill

Skeptic + CI findings folded into one pass:

- Coverage probe: a failed AR lookup now degrades to UNKNOWN
  (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete".
- Probe driven from journal_entries (company-indexed) with the AR line
  condition as an inner embed, instead of the lines-table-with-embed-filters
  shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts).
- DEBIT-only 1510/1513 lines; excludes every invoice-engine source type
  (invoice_created, invoice_paid, invoice_cash_payment, credit_note,
  reminder_fee, rot_rut_payout, storno, correction): an advance payment
  crediting 1510 or a re-dated rattelse of an engine entry no longer flags.
- covers_from ignores drafts so a backdated draft cannot move the boundary.
- Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR
  debits existing IN the reconciled period (new
  ARReconciliationResult.pre_register_ar_in_period): prior-period migration
  history cannot explain this period's difference and must not excuse a
  real felbokning. Wording no longer says "snarare an felbokning".
- MCP coverage_note states the earliest register invoice date rather than
  claiming the register "covers" from it.
- Amount search compares magnitudes so credit notes (negative totals) are
  findable; "-17500" parses; null amounts never match "0".
- skills/accounted-api regenerated from the registry (apiskill:check).

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

* chore(api-skill): regenerate accounted-api skill after merging origin/main

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

* fix(invoices): round-2 review fixes for register-coverage disclosure

- covers_from now anchors on real invoices only (document_type='invoice',
  non-draft): proformas/delivery notes cannot move the boundary.
- INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers
  (invoice-entries, reminder-fee, rot-rut, storno-service) so a future
  source_type cannot silently become false pre-register evidence.
- Kundreskontra guidance names both 1510 and 1513.
- MCP gnubok_list_invoices outputSchema declares invoice_register_coverage
  and coverage_note.
- v1 reports.ar-ledger documents data.register_coverage; invoices.list
  example made internally consistent; api skill regenerated.

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

* fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget

The expanded schema from the round-2 review pushed tools/list to 61 726
tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The
ceiling is policy, not a baseline to bump: the description already tells
agents to read invoice_register_coverage/coverage_note, and paginatedSchema
has no additionalProperties:false, so the fields stay schema-valid.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 09:39:14 +02:00
Jakob Wennberg 01d903d3a3 fix(mcp): link_document_to_voucher marks the inbox item handled; document lists stop misleading agents (#2170)
Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit
the same hole: link_document_to_voucher attaches the document to the
verifikat but never stamps the inbox item it came from, and both inbox read
surfaces derive "handled" from the inbox row's own link columns, never from
document_attachments.journal_entry_id. So an attached document stayed
"unprocessed" forever: agents re-saw it as missing underlag (one reporter
paged 750 rows to find the ~15 real ones), and one user read the five
leftover rows as duplicates and was about to delete the only copies of
underlag sitting on posted verifikat.

- commitLinkDocumentToVoucher and the bulk twin now stamp
  invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on
  both link columns, 23505 tolerated (samlingsverifikat). Same shape as the
  create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping
  never rolls back a committed link.
- gnubok_list_unmatched_documents returns file_name (same embed
  list_inbox_items uses) and the extraction's page coverage, so an agent can
  tell "no total on this document" from "we read 3 of 38 pages" and does not
  have to fetch each document to learn what it is (seq 265062, 288574).
- gnubok_list_transactions_without_documents no longer echoes the column
  default "uncategorized" on rows that are booked by construction:
  list_uncategorized_transactions uses the same word for "no journal entry
  yet", and an agent read the label and tried to re-book an already-booked
  share-capital deposit (seq 288574).

No tools/list payload change: the unmatched-documents item schema is
untyped and the category field already allowed null.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 08:57:53 +02:00
Mattsson 9ab823a43c fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies (#2260)
* fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies

#2233 merged, but its first migration (20260903150000) failed on prod at
the backfill's INSERT into invoice_payee_defaults:

  ERROR: Archived migration reset source records are immutable (P0001)

The insert fires the SECURITY DEFINER mirror into company_settings, and
one of the companies with a legacy payment map is a migration-reset
source, whose rows are immutable by trigger. The migration rolled back as
a whole, prod has neither table nor column, and every migration merged
after it is queued behind the failure.

Same fix as #2249 used for the country backfill: both entry branches of
the backfill now skip companies present in company_migration_resets, and
both files are re-issued under fresh versions (20260904010000 and
20260904011000) so Supabase applies them in order after everything that
landed today. The failed versions never applied on prod, so no orphan;
staging applied them by hand and its schema_migrations rows must be
renamed to match (see DECISIONS.md).

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

* fix(migrations): make the re-issued payee migrations rerunnable

pg-upgrade builds from main, where the first issue (20260903150000)
already ran, then applies the re-issued file on top: the composite UNIQUE
constraint already existed. Every statement in both files is now guarded
(constraint DO blocks, CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY /
DROP TRIGGER IF EXISTS before each CREATE), so staging and the preview
branches that applied the first issue take the re-issue cleanly too, and
prod, which never applied it, is unaffected.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 06:45:56 +02:00
Jakob Wennberg 91e2c66afc fix(parties): readable suggestions from assistant vouchers, auto-build queue, SCB fetch after promotion (#2259)
* fix(parties): readable suggestions from assistant-written vouchers, auto-build queue, SCB fetch after promotion

Live feedback on a real company (2026-09-03): the queue showed 35 one-off
suggestions with sentence-long names, wide empty rows, a "Hämta förslag"
step nobody could predict, no SCB fetch after promotion, and an empty
supplier created from a Finansinspektionen fee line.

- ledger_key v2 (migration 20260904002000): keep the counterpart head of
  "<counterpart> · <note>" descriptions, drop bank method tokens and long
  references before normalising; JS mirror in lib/parties/ledger-key.ts
  with shared LEDGER_KEY_CASES. Suggested parties nobody has touched are
  rebuilt under the new keys (repair in the same migration).
- apply_party_suggestions attaches by VAT number too, so ledger keys with
  a VAT number but no org number reach existing roles.
- Queue: fixed name/reason column widths, inline "Hitta i
  företagsregistret" for rows without an org number.
- Page: builds the queue automatically on first visit when nothing has
  been suggested yet; after promotion, fetches SCB facts for every
  promoted legal person (spaced under the 10 calls/10 s limit) and fills
  the role's VAT number; confirm dialog says how many rows lack an org
  number.
- Classifier: more authorities (Finansinspektionen, Arbetsförmedlingen,
  Pensionsmyndigheten, ...) and fee words (registreringsavgift,
  tillsynsavgift, ...) so fee lines stop becoming suppliers.

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

* fix(parties): scope the suggestion repair to keys the new ledger_key no longer produces

Superagent flagged the repair DELETE as global. It now only removes
untouched pipeline suggestions that no posted voucher of the company maps
to under the new function; suggestions whose key is unchanged stay.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:19:12 +02:00
Mattsson d670fe6663 feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account

Enable Banking has no top-level `bban` key on AccountIdentification: a
Swedish BBAN (clearing + account number) arrives as `other.identification`
with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed
`bban?: string` and read `.bban`, so the value was always undefined: no
connected account ever carried its clearing + account number, and domestic
counterparty accounts on transactions were dropped.

Type the identifiers per the OpenAPI spec, add extractBban() and
pickAccountIdentifier(), read counterparty identifiers through the scheme
list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on
StoredAccount from the OAuth callback. The external_id dedup scope stays
IBAN-then-uid and is untouched.

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

* feat(invoices): named payee accounts on cash_accounts with a default per currency

A company had exactly one set of payment instructions per invoice currency
(company_settings.invoice_payment_accounts), picked by currency alone. A
second SEK bank account, or a second bankgiro number, had nowhere to live.

cash_accounts is already the per-company bank-account entity. Migration
20260903150000 adds the payee fields (bankgiro, plusgiro, clearing +
account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a
small invoice_payee_defaults table (one default account per currency; one
account may be the default for several currencies, a SEK account with an
IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites
the legacy map and the SEK bank columns from the default accounts. Every
existing reader (PDF, email, reminders, v1, MCP) keeps working; the three
writers that only touched legacy columns (PUT /api/settings, v1 settings,
MCP update_company_settings) now write through to the default account, so
what an agent sets is what the PDF prints. Peppol PaymentMeans is built
from the resolver instead of the raw legacy column. bg_pg is dropped
(never read or written; NULL on every prod and staging row).

Backfill lands only on existing cash accounts (primary, IBAN match, or the
only enabled account in the currency). Entries with no target stay in the
map as the resolver fallback and get an attach action in settings.

New: POST /api/cash-accounts (manual bank account on the next free 19xx),
PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT
/api/cash-accounts/payee-defaults. Settings page rewritten as an account
list with per-currency defaults. Behandlingshistorik and the full archive
cover the new table and columns.

Verified on staging: migration applied (11 defaults landed), mirror
trigger observed rewriting company_settings from a payee edit.

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

* feat(invoices): choose which bank account an invoice is paid to, frozen at issue

Migration 20260903160000 adds invoices.payment_cash_account_id (FK to
cash_accounts, SET NULL) and invoices.payment_details, the payee fields
frozen when the account is chosen and refreshed at issue.

Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount /
assertInvoicePaymentAccountForRender take an optional override, and
hasRequiredInvoicePaymentAccount reads it from the invoice row, so every
surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol,
recurring, staged MCP send) prints the frozen payee when one exists and the
company default per currency otherwise. Invoices that never chose an
account behave exactly as before.

Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send,
recurring, MCP send and mark-sent) refresh the snapshot from the account as
it is at issue; a chosen account that is disabled, un-flagged or unusable
for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID.

Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice
accept payment_cash_account_id and validate it against the company's payee
accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the
original's payee; copies carry the choice; preview-pdf renders the chosen
account. The editor shows "Betalas till" under the currency when the
company has two or more usable payee accounts for that currency.

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

* feat(invoices): book manual payments on the invoice's chosen bank account

Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the
booking dialog's proposed lines debited 1930 regardless of which bank
account the invoice asked to be paid to. They now resolve the chosen
payee account's ledger account (resolveInvoiceSettlementAccount) and fall
back to 1930 only when no account was chosen or the row is gone.

Bank-transaction matching keeps debiting the account the money landed on
and does not filter by the chosen account; between equal-confidence
candidates it prefers the invoice that asked to be paid to the landing
account. Scores are untouched, so nothing new auto-matches.

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

* chore(invoices): keep the payload-size and phantom-column ceilings after the payee work

Shorten the new gnubok_create_invoice argument description (tools/list
payload was 29 bytes over the 60 kB budget), inline the cash-account payee
UPDATE/INSERT payloads and the settings select strings as literals so the
phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE
instead of a hand-rolled copy.

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

* fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK)

Review findings from CodeRabbit, Superagent, the Swedish accounting review
and three skeptic passes, resolved in one batch:

Schema (both migrations are unshipped and edited in place):
- cash_accounts.payee_iban: the printed IBAN is its own column. iban stays
  the bank identity written by every sync and used to re-pair on reconnect,
  so a sync can no longer rewrite an invoice instruction or resurrect a
  cleared IBAN. The backfill copies each currency entry verbatim onto the
  target account (IBAN match first, then primary), so every invoice keeps
  printing exactly what it printed before; the bank IBAN is never pushed
  onto invoices that did not carry one.
- Payee columns are owner/admin-only at the database (BEFORE trigger,
  service role exempt): cash_accounts is member-writable for bank sync, and
  the SECURITY DEFINER mirror would otherwise have let a member rewrite
  where customers pay.
- Revoking an account as payee or disabling it drops its defaults; deleting
  a default drops that currency from the map and clears the legacy SEK
  columns (an admin saying "nothing to print" must not keep printing a
  closed account). The mirror leaves the legacy SEK columns alone when the
  map has no SEK entry, so legacy-only companies are never wiped by a
  mirror run for another currency.
- Audit and mirror triggers fire on the same column set; anon and
  authenticated can no longer execute the trigger-only definer functions.
- invoices.payment_cash_account_id is a composite same-company FK with
  SET NULL scoped to the account column.

Code:
- Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now
  also requires enabled, payee-flagged and usable for the currency),
  resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which
  also refuses disabled rows and logs every fallback to 1930).
- createManualBankAccount excludes every ledger slot any row already holds
  (findFreeLedgerAccount treats a manual holder as free; this path inserts).
- The legacy settings writers (PUT /api/settings, v1, MCP) write through to
  the account BEFORE updating company_settings and fail the request on
  error; the account is written before it is adopted as default so the
  mirror never sees an empty payee.
- snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write
  blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid
  projections carry the payee columns; v1 create validates the payee
  before the dry-run return and echoes it in the preview.
- pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and
  non-account schemes (card PANs) are never persisted.
- Editor shows the payee select for a single usable account with no
  default; the booking dialog waits for cash accounts before proposing
  lines; a failed default write no longer hides a created account.
- Behandlingshistorik names the account on created/deleted defaults.
- Regenerated skills/accounted-api; MCP argument description trimmed under
  the tools/list payload ceiling.

Declined: clearing legacy columns via a forward migration (the mirror now
does it on delete); Swedish review's "show the debit account in the
mark-paid UI" (the booking dialog already proposes and lets the user edit
the debit line); manual ledger collision (UNIQUE exists, and the create
path now rejects it with a clear error); Peppol aligning to the PDF value
for companies whose legacy column had drifted from the map (the PDF is the
customer-facing document; both now agree).

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

* fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger

trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves
record fields per expression, so the combined condition failed with
"record new has no field invoice_payee" whenever a default row changed,
which took down every pg-real case on the payee tables. The revoke/disable
check now sits inside its own TG_TABLE_NAME branch. The MCP settings
executor test mocks the payee write-through like the settings route test
already does.

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

* fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload

Cycle 3 of /resolve-pr on #2233.

Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's
invoice_payee_defaults rows whenever cash_accounts.enabled flipped to
false, and enabled is member-writable (the bank picker's "Synkas ej"), so
a member could undo an admin's payee decision. The trigger now drops
defaults only on the admin-only invoice_payee true -> false revoke; the
mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out
of the pick lists and the send gate already refuses an invoice that chose
one. Applied to staging as the same function + trigger definition and
probed inside a rolled-back block: disable keeps the default and the
mirrored bankgiro, revoke clears both.

pg-real: the admin-guard test ran three expectations inside one
withUserContext transaction; the first raise aborted it and the next
statement failed with "current transaction is aborted". One transaction
per expectation now, and the member case also flips enabled to prove the
column stays member-level.

Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa
and the 1911-1919 tills. A customer pays to a giro or bank account, so
isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH
route now require BAS 1920-1999; tests cover 1910 and 1919.

Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014
tokens after main merged #2166 and #2163 alongside this branch. The
ceiling is not bumped and no read on this surface is a demotion
candidate, so gnubok_create_invoice drops payment_cash_account_id;
agent-created invoices print the per-currency default and v1 REST plus
the editor keep the field. Recorded in DECISIONS.md.

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

* chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration

origin/main merged 20260903160000_kpi_monthly_include_reversed_originals
while this branch held the same version; identical versions abort the
Supabase apply. Staging's schema_migrations row was moved to the new
version with the file.

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

* fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet

Cycle 4 of /resolve-pr on #2233, on Emil's go.

Swedish review: the 1920-1999 payee rule lived only in the routes. The
cash_accounts_payee_admin_only trigger now also refuses invoice_payee on
any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes
it, and the backfill only targets giro/bank rows, so a company whose
single enabled cash_accounts row is a Stripe clearing account keeps its
legacy bankgiro in company_settings instead of landing it on 1686. pg
test covers insert and update on 1686 and 1910; the function was applied
to staging and probed.

Typecheck ratchet: main is red from two merges that landed with failing
Checks, and every branch that syncs it inherits the errors.
  - #2242 added POST(req) calls to the fiscal-periods route test without
    the route params argument withRouteContext handlers take (25 errors
    in the file, baseline 23). All 25 calls now pass
    createMockRouteParams({}).
  - #2247 made SyncResult.requestedFromDate and historyNarrowed required;
    the 13 mockedSync results in the enable-banking accounts-route test
    lacked them. They now carry a fixed date and historyNarrowed: false.
Both files' tests pass unchanged in behaviour.

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

* chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion

origin/main merged 20260903183000_party_promotion while this branch held
the same version. Staging's schema_migrations row must follow (pending:
the Supabase MCP was disconnected at the time of this commit).

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:06:45 +02:00
Mattsson 88a5d78594 fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) (#2244)
* fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181)

A mail sent to both the +lev and +ver address of one inbox was read as
its first recipient only, and an attachment whose processing threw left
no row at all: the webhook answered 200, Resend never retried, and the
document was gone with nothing for the user to find. Prod showed both
shapes for the reporter (a +lev mail Resend accepted with zero inbox
rows, and the second PDF of the +ver mail missing).

- The webhook now reads every shared-domain recipient, groups them per
  inbox, files once per inbox with a company-scoped dedupe key, and
  resolves contradicting tags (+lev and +ver on one mail) to no hint so
  extraction classifies.
- The per-attachment catch writes an error row instead of only a
  console line.
- One InboundMailReceived behandlingshistorik event per mail and inbox
  records recipients, tags, hint, conflict and the outcome per
  attachment (filed, duplicate, rejected, failed). No sender or
  subject, matching the existing PII rule.
- GET /inbound-history?days=30 serves those events, company-scoped, and
  the inbox workspace shows them under Källor as "Inkomna mejl", each
  filed row a click away.
- The list says how many rows the type filter is hiding, with a click
  back to all types.
- Migration 20260903190000 registers the event type and replaces the
  (email, attachment) unique index with (company, email, attachment).

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

* fix(inbox): keep addresses and sender-typed tags out of the mail record, and let redelivery heal a transient failure

Skeptic pass on #2244, two refutations:

- The InboundMailReceived payload carried the recipient addresses and every
  plus-tag verbatim. An enskild firma's inbox local part is the owner's
  name, the tag is whatever the sender typed, and processing_history is
  append-only and outside the erasure path; a numeric tag also tripped the
  PII validator so the record was silently dropped. The event now carries
  inbox_id, the documented tags (+lev/+ver), an unknown-tag count and the
  outcome codes. The history route resolves inbox_id to the company's own
  address at read time. The DB strip trigger from 20260901110000 covers the
  new type (and is recreated, since staging skipped that file).
- The catch-path error row made a Resend redelivery report "duplicate", so
  a transient download or storage failure that used to self-heal on retry
  became permanent. The row is marked transient and a redelivery replaces
  it; rejections (bad type, too large) stay duplicates.

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

* fix(inbox): cap inbound fan-out, flag a truncated mail history, and name a replaced transient row

Review pass on #2244: Superagent (bound the number of inboxes one mail can
fan out to: five), CodeRabbit (the history route now returns has_more past
200 rows and the panel says so instead of "every mail"), and the Swedish
accounting review (a redelivery that replaces a transient error row names
the replaced row on the InboundMailReceived record, so the replacement
leaves a trace). The migration comment states why the index swap is not
CONCURRENTLY: Supabase branching applies migrations in a transaction.

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

* fix(inbox): resolve every addressed inbox and record the ones past the fan-out cap

CodeRabbit and the Swedish accounting review on #2244: slicing recipient
groups before the lookup let five unknown local parts starve a real inbox
and left companies past the cap with no trace. Every addressed inbox is
now resolved (one cheap lookup each), the first five are processed, and
the rest get their own InboundMailReceived record with outcome
fan_out_capped, shown in the panel as "not processed".

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

* chore(inbox): move the inbound-mail migration past the parties versions merged tonight

Main moved party_decision_undo to 20260904000100 and added
20260904000200 (#2257, #2258). A version below prod's head is skipped by
Supabase branching, so 20260903190000 becomes 20260904001000 unchanged.
Staging re-tracked under the new version.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:47:24 +02:00
Mattsson b07efcafd4 fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)
* fix(payroll): require jamkning valid_to on every write path (#2058)

A jamkningsbeslut saved through the v1 API or MCP with a percentage and a
start date but no end date was stored and returned 200, yet the engine
(isJamkningValid) never applies a beslut without both dates: the payslip
and the AGI carried the table tax while the caller believed the beslut
was live.

One shared validator (lib/salary/jamkning-rules.ts) now requires both
dates whenever a percentage is set and checks their ordering. Every write
path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST
and PATCH routes, the v1 PATCH route (its private copy is deleted), the
MCP create and update executors in employee-commands, and the MCP update
tool preflights the merged row at staging time so the agent sees the
error before approval. The update paths keep the existing touched gate, so
legacy rows stored without valid_to stay editable in unrelated ways.

The MCP tool descriptions state that both dates are required for the
beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing
rows (percentage set, valid_to null) per company, read-only; setting an
end date or clearing the beslut is decided per company since either
changes the next payslip.

Declined: defaulting valid_to to 31 December of the from-year. It matches
most beslut but silently changes withholding on rows that today do
nothing.

Closes #2058

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

* fix(payroll): keep the jamkning PR inside the type and tools/list budgets

CI on the first push failed on two ratchets this PR itself tripped:

- Typecheck ratchet: the three staging tests added here reused the
  untyped 'agent_chat' actor literal the file already carried, which
  raised that file's error count above its baseline. They now pass
  { type: 'user' }.
- tools/list payload budget: the first jamkning field descriptions on
  gnubok_create_employee and gnubok_update_employee pushed the projected
  catalog to 60 113 tokens against the 60 000 ceiling. The percentage
  fields keep a one-line "needs both dates or never applied" note; the
  date fields drop theirs.

Also acts on the compliance swarm's GDPR Art.32 note: the read-only
lister no longer selects employee names at all (the employee id is what
the per-company decision needs), so the script touches no PII.

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

* docs(mcp): say the jamkning percentage is rejected without both dates

CodeRabbit on #2240: "never applied" described the pre-fix engine
behaviour; the contract now is that a create or update with a percentage
and a missing date is rejected before staging. Same length, so the
tools/list payload budget is unchanged. The concurrency finding is
tracked in #2256 instead of this PR.

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

* fix(mcp): keep tools/list under budget after proforma landed on main

After merging main (#2254 proforma fields) the projected tools/list
measured 60 010 tokens against the 60 000 ceiling with this PR's two
jamkning field notes. Per the budget test's own rule, demote a read tool
instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes
search-only. Versions exist only once a report is rendered for signing
or filing, which is the same switched-off iXBRL path as its sibling
gnubok_get_arsredovisning_filing_status, already search-only since
2026-09-02. Still reachable via gnubok_call_tool.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:22:41 +02:00