1676 Commits

Author SHA1 Message Date
Jakob Wennberg 4bc007cdeb fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2354)
* fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2351)

The PS reads entries with status posted or reversed, so a stornoed EU
invoice voucher kept its 3308/3108 credit while the storno that nets it
was dropped: its source_type is 'storno' and no register row points at
it. A makulerad EU sale was over-reported while the account-based ruta 39
was zero.

Which invoice explains an entry is now resolved by one composed helper,
getInvoicesExplainingJournalEntries (lib/core/bookkeeping/
journal-entry-references.ts): the engine's own source_id, the invoice-side
rows, and the rättelse chain through correction_of_id / reverses_id, walked
upwards under the MAX_CHAIN_WALK cap correction-chain.ts already uses.
Parents outside the batch are fetched by id, company-scoped. The link
columns are followed rather than the storno's copied source_id because
correctEntry() copies none onto its storno or correction, and a storno's
copied source_id is polymorphic (a bank row on a bank booking).

getInvoiceReferencesForJournalEntries (the RPC mirror behind the underlag
surfaces) is unchanged: storno and correction are not doc-requiring source
types, so those surfaces have no such hole.

- INVOICE_SOURCED_ENTRY_TYPES / LINK_LOOKUP_CHUNK move to the helper module
  (the set now names every engine type whose source_id is an invoice).
- PS: one resolver call; reverses_id, correction_of_id in the select; the
  ZERO_NET_EXCLUDED text names makulering beside kreditfaktura.
- Tests: resolver cases (chain inheritance, out-of-batch fetch, own link
  wins, mirror, cycle, cap) and PS cases (the issue's storno nets to
  ZERO_NET_EXCLUDED, rättelse chain, later-period storno, mirror, linked
  import, gone invoice).

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

* fix(bookkeeping): PR #2354 review: bound in-batch chain propagation at MAX_CHAIN_WALK

getInvoicesExplainingJournalEntries attributed an in-batch chain of
stornos/corrections all the way down through a recursive assign over
waitingOn, while parents fetched from outside the batch stopped at
MAX_CHAIN_WALK. The two paths now agree: every attribution carries its
depth from the root (0 for an entry resolved by its own source_id or
invoice-side link), propagation is an explicit queue instead of recursion,
and a descendant more than MAX_CHAIN_WALK links below the root resolves to
no invoice, both when it inherits from an already attributed parent and
when it is reached by propagation. Test: an in-batch chain of
MAX_CHAIN_WALK + 1 links, in both batch orders, attributes the links within
the cap and not the one beyond it, with no parent fetch.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:10:11 +02:00
Jakob Wennberg 6a5fd6cd00 feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)
A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:05:43 +02:00
Jakob Wennberg 6906bc4aa2 feat(compliance): InvoiceRowsCompleted behandlingshistorik event for migrated invoice rows (#2312) (#2357)
Every migrated sales invoice whose rows complete_invoice_rows writes, from
the migration wizard or the hourly row-completion pass, now leaves one
InvoiceRowsCompleted row in processing_history on a new Invoice aggregate:
the writer, the provider, the consent, the row count, and the header VAT
split before and after when the pass rewrote it (BFL 5 kap 11 §, BFNAR
2013:2 p. 9.16). One run shares one correlation id.

lib/invoices/complete-invoice-rows.ts is the one TypeScript call site for
the RPC and the one emitter: it appends only on wrote = true, records
nothing for already_filled or failed, and keeps the append best-effort
(logged, eventId null) like every other processing_history writer. The
wizard runs on the user's session client, so MigrationOptions takes a lazy
createHistoryClient for the service role. Invoice numbers stay out of the
payload (the personnummer guard would drop ten-digit ones).

Migration 20260906210100 widens the aggregate_type CHECK with Invoice and
registers the event type; pg test covers the catalog row, the aggregate,
and that the CHECK still refuses unknown aggregates.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:04:53 +02:00
Jakob Wennberg ebbe50c0f3 feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)
The supplier-invoice form asks who paid with the same control as the
Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead
of its own switch under Förval. A person paying is an utlägg: the route
hands the invoice to registerExpenseClaim with the invoice's kontering as
the claim's lines, so the verifikat and the expense_claims row come from
the same writer as the Underlag pane, the person shows up under "Betala ut
utlägg" on Hem and the bank matcher closes the debt. Employees book on
2820 with employee_id; the owner's blank name falls back to the shared
label so Hem groups one person.

Also routes a person-paid inbox document through the core route with
inbox_item_id: the extension's convert endpoint never read
paid_with_private_funds, so the old switch was silently dropped whenever
a receipt was attached. The second entry generator, the Förval switch,
the outline "Registrera & markera som betald" button and the duplicated
owner/employee picker are removed; PayerChoiceSelect and the claimant
fields move to components/expenses so core and the extension share them.

Closes #2332


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:04:17 +02:00
Jakob Wennberg 6776cb4fc6 feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)
* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293)

The bridge table said "ej matchad" and steered to Bokför when a Bankgirot
aggregate was already booked as two or three unlinked vouchers. The booking
doors have refused that double booking since #2300 and #2346 with
detectExplainingVoucherSet; the view never ran it.

- duplicate-payment-detection: split the set detector into fetch and pure
  steps and add detectExplainingVoucherSets, the batch form (one ledger
  scan, one anchor lookup, per-row verdict identical to the single
  detector; a voucher explains at most one row per call). ExplainingVoucher
  now also carries voucher_series and voucher_number.
- reconciliation/covering-set-candidate (new): maps sets to proposals
  (0.95 same date, 0.85 within seven days), SEK accounts only, fails open.
- items: open bank rows nothing explains 1:1 are searched before they land
  in unmatched_external; a hit lands in proposed with proposal.vouchers.
- schemas: ReconciliationProposal.vouchers (optional, set proposals only).
- AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that
  posts every voucher as a 1:N pair to the existing links route.
- i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en).

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

* chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293)

The set proposal field added to the reconciliation items response shape
flows into the generated agent skill; regenerated with
`npm run apiskill:generate`, which changes one line of references/banking.md.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:03:42 +02:00
Jakob Wennberg fcda4a75dd feat(parties): fill customer and supplier forms from the register on a valid org number (#2355)
The registry lookup was built for rows that already exist (the detail
page's "Hämta uppgifter" records facts on the row's party), so on the
create form people typed what SCB already knew. Org number now comes
first in both forms; a complete, check-digit valid number of a Swedish
legal person is looked up once and fills name, address, postal code,
city (and the VAT number where the form shows one) wherever nothing has
been typed. A typed value is never replaced; a corrected number replaces
only its own earlier fill. A personnummer never reaches the register
(client key and server gate), and an environment without SCB credentials
answers 503 once and the form stays quiet.

- GET /api/parties/registry?org_number=: read-only route over the same
  SCB client, credential gate and registrySummary reader as the enrich
  route; writes nothing.
- lib/parties/registry-form-fill.ts: registryLookupKey (one rule for
  what may be looked up) and registryFormFill (contactFill's untouched
  rule plus name and VAT number), pure and tested.
- components/parties/use-registry-autofill.ts + RegistryAutofillNote:
  debounced, once per distinct number, skips the number an edit dialog
  opened with, one muted line under the field.
- Adressrad 2 is now an input on both forms: the register's c/o goes on
  line 1 with the street on line 2, as on the row, and both edit dialogs
  already passed the column in.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:03:06 +02:00
Jakob Wennberg 3b9daf2606 fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the
  payout RPC applies, so "Jakob" and "jakob " are one person with one
  Att göra row and one exact-amount match.
- The inbox pages through every registered claim (fetchAllRows) before
  pairing, so a long backlog can never understate a person's debt.
- A foreign receipt's VAT field is locked at 0 and 0 is what is submitted.
- Transport failures in the one-click and picker confirms show the
  destructive toast instead of failing silently.
- The open-claims flag is set only after the stale-fetch guard, and a
  payout match decrements the inbox count like every other row exit.
- Test: reset the live-link mock before the bank_line junction case.
- Wording: "Återbetalning av utlägg".

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:18 +02:00
Mattsson 1a725c121a fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel (#2349)
* fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel

Sends now say HOW they failed (failure: http_rejected | transport_error),
and the company question falls back to numbered text only on an HTTP
rejection. A timeout means Meta may already have delivered the interactive
question, so that case rolls the question back instead of putting a second
copy on the phone; the next receipt re-asks.

Both drains (the company answer and the single-live-company path) share
one helper that stamps rows older than Meta's ~30-day media retention as
company_choice_expired instead of re-opening them into the MAX_ATTEMPTS
error path, and tell the sender once (M20) how many receipts could not be
recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep
and the drains read one definition.

POST /link/default-company checks LIVE membership with the same
companies!inner(archived_at) filter intake uses, so a default pointing at
an archived company is refused (403) instead of saved and then silently
ignored; a failed membership read is a 500, not a 403.

consumeLinkCode is tri-state: a failed lookup or claim returns
'transient_error' and the webhook answers with a neutral retry (M21)
instead of M2 "the code is wrong" to a user holding a valid code. In
degraded mode (quota RPC down too) M21 sits behind the same fail-closed
throttle as M2.

Refs #2062

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

* fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle

Review pass on #2349 (CodeRabbit, four findings, one commit):

- drainParkedRows reads the error of both UPDATEs, logs each, and returns
  failed: true. The answer stays applied (pin set, options claimed) and the
  single-company path still clears the dead question: both turn the rows
  that are still parked into orphans, and a new sweep pass re-opens parked
  rows whose conversation has no open company question (older than two
  minutes, so a row parked just before its question is armed is left
  alone) through the same drain and processes them.
- badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a
  lookup outage cannot earn one M21 per message in degraded mode.
- The M20 expiry notice logs a failed send. It stays best-effort like M17,
  M18 and M19: the extension has no durable outbound retry, and the rows
  the notice describes are already terminal.

Refs #2062

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

* fix(whatsapp): type the orphan scan rows and the cron summary fixture

reopenedOrphans joined SweepSummary in the previous commit; the cron route
test's fixture and the PostgREST embed cast in the orphan pass had not
followed (check:types caught both).

Refs #2062

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:14:06 +02:00
Mattsson 92a734f2b3 fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC (#2350)
* fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC

Rows marked as business before categorize failed closed (#1990) but never
given a verifikat sit as is_business = true with no anchor in any of the
three booking locations. The worklist predicate is is_business IS NULL, so
they are unbooked and invisible: silent missing lopande bokforing.

repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked,
p_actor, p_correlation_id) lists the stranded shape (dry run, default) or,
for one company, resets the same triple the engine's storno path resets
(is_business, category, reconciliation_method) so the rows return to Att
bokfora. The UPDATE re-asserts the full predicate in the same statement,
never touches a journal entry, and writes one BankTransactionStrandedRepaired
behandlingshistorik event per row in the same transaction. service_role
only; a write needs a company id and an actor.

scripts/repair-stranded-categorized-transactions.ts prints the per-company
breakdown split by sandbox and lock state, and writes only after a typed
confirmation that repeats the row count. The prod run is a founder decision
per company and is not part of this change.

Refs #2057

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

* fix(transactions): leave locked-period rows alone by default in the stranded-row repair

Swedish compliance review on #2350: a row returned to Att bokfora inside a
locked or closed period cannot be booked in place (BFL 5 kap 5 § keeps
closed periods on the rattelse track), so reopening it for triage must be
an explicit operator choice. p_skip_locked now defaults to true; the script
lists those rows and resets them only with --include-locked. The pg test
covers both the default and the explicit override.

Refs #2350

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:59:32 +02:00
Mattsson 3c033e466f fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole (#2348)
* fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole

A residual booking anchors a bank row twice: the pointer column holds the
main verifikat and one transaction_voucher_links row of role 'other' holds
the small residual verifikat. reverseEntry reset the pointer unconditionally
and left the 'other' row behind, so the row split across surfaces: the
worklist showed it as att bokfora (is_business IS NULL) while every reader
that counts junction rows (the unmatched list behind BookDirectlyDialog, the
bulk_book_transactions RPC, is_transaction_booked(), the reconciliation
bridge) went on calling it booked. Bulk-book refused it with
BULK_BOOK_TX_ALREADY_BOOKED on a row displayed as unbooked.

reverseEntry now reads the rows whose pointer it is about to reset and drops
their junction rows to any other verifikat right after the reset, before the
existing cleanup of the reversed entry's own junction rows. No anchor
survives, so every reader agrees without a role fork or a migration; the
residual verifikat stays posted and surfaces as unmatched, which is honest
because its main sibling is gone. This mirrors what koppla-bort and the 1:N
partial-split path already do.

Tests: engine.test.ts gains the residual case and the no-pointer case and
pins the pointer read before the reset; the opening-balance mock learns the
read. The bank_line-only re-booking guards from #2029 stay as defense for
rows left behind before this change (prod holds zero such rows).

Fixes #2061

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

* fix(bookkeeping): release reversed-entry transactions and drop their supplementary links in one RPC statement

Review finding on #2348 (CodeRabbit, Swedish review note): the pointer read,
the pointer reset and the supplementary-link delete were three PostgREST
statements. A failed read left the links behind with the pointer already
reset, the exact half-anchored row #2061 describes, and a link created
between the reset and the delete would have been removed from a stale id set.

release_reversed_entry_transactions(p_company_id, p_entry_id) does both in a
single data-modifying CTE under the UPDATE's row locks and one snapshot: the
DELETE only sees links that existed when the statement started and only for
the rows the UPDATE actually released. SECURITY INVOKER, so RLS and the
writer-role trigger apply exactly as they did to the direct statements.
Links to the reversed entry itself are still left to the engine's junction
cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same
entry).

Migration 20260906172540 applied to staging and covered by
tests/pg/release-reversed-entry-transactions.pg.test.ts (main storno releases
whole, residual storno touches nothing, bank_line-to-self left for the
junction cleanup, tenant scope, viewer refused). Engine unit tests pin the
RPC call and the best-effort fallthrough on RPC error.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:45:03 +02:00
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 39d409d257 fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them (#2313) (#2340)
* fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them

The row-completion pass (#2291) and the migration wizard both wrote
invoice_items for migrated sales invoices with check-then-insert across
separate statements and nothing serializing them per invoice; the pass
also wrote the header VAT split in a third statement, so "rows landed,
header did not" was reachable and never revisited.

Adds complete_invoice_rows (SECURITY DEFINER, FOR UPDATE on the invoice
scoped to the company, inserts only when the invoice still has no rows,
optional header split in the same transaction, returns wrote) and routes
both writers through it: the pass one call per invoice (wrote = false is
skipped, not completed), the wizard one call per invoice in small
concurrent groups. Unknown row keys and partial headers are refused
rather than dropped. Grants: revoked from PUBLIC and anon, kept for
authenticated (membership gate in the body) and service_role.

pg test proves the invariant (first call writes, second returns wrote =
false with rows and header unchanged), the rollback of rows on a failing
header, every refusal, the grants and two-connection serialization.

Closes #2313

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

* fix(invoices): complete_invoice_rows requires the row's tax facts instead of defaulting vat_rate to 25

Review finding on #2340: COALESCE(r.vat_rate, 25) let a row without a
rate land with a fabricated 25 % (ML 17 kap 24 § p.9). Both writers
always send vat_rate, line_total, vat_amount and description, so the
defaults were never needed and only hid a bug. The RPC now refuses a
row missing any of the four (absent or JSON null) with
MISSING_REQUIRED naming the column; sort_order, quantity, unit and
line_type keep their table defaults since none states a tax fact.
The rate's value is deliberately not restricted to the Swedish set:
0 (omvänd skattskyldighet, export) and foreign rates (OSS) are
legitimate on a migrated row, and the pg test pins both as accepted.

Migration edited in place: unshipped, preview branches only.

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:55:42 +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 8313f527c9 fix(migration): complete-invoice-lines cron visits registers smallest first (#2341)
* fix(migration): complete-invoice-lines cron visits registers smallest first

The hourly pass ordered its work by consent recency, which says nothing
about work size: a 1 125-invoice register on the newest consent used two
runs in a row while a 384-invoice register three consents older was
skipped for budget both times. Each run now sizes every usable consent's
register on our side first (one indexed HEAD count of the non-draft
invoices without rows, no provider call) and then hands the registers
with anything left to the pass smallest first, each within its share of
the run. Shortest job first: a register that fits its share is done this
run whatever was accepted after it; the one that needs several runs takes
what is left of each. Nothing is stored between runs, and the budget
constants are unchanged.

What a run does not reach is by construction its largest registers; they
are logged and returned as `deferred` with their counts so a register
that is deferred hour after hour is visible. The count is proven against
a real PostgREST (tool-pg) because `invoice_items=is.null` on a to-many
embed is resolved there, not in Postgres or the type system.

Closes #2309

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

* test(schema): teach the phantom-column guard PostgREST's embed-null filter

The static guard read `.is('invoice_items', null)` as a column of
`invoices` and failed CI on #2341. PostgREST's null filter on an embedded
resource (`?invoice_items=is.null`, the anti-join on a to-many embed:
the parents whose embed is empty) names the embed declared in the same
chain's select, not a column. The scanner already registers every embed
alias per chain for dotted filters; a bare name that is a registered
embed, used with `is` (or `not` / `filter` with the `is` operator, the
only operators that reach an embed), is now recognised and checked no
further. Any other operator on a bare embed name, and `is` on a name the
select never embedded, are still accused, with cases for both. The
grammar itself is proven on a real PostgREST by
complete-invoice-lines-count.tool.test.ts.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:38:31 +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 0ecf1d7fc4 fix(expenses): no help line for "Företaget" in the Vem betalade select (#2330)
The button under it already says "Matcha mot transaktion", so the line
"Kort eller bankkonto. Matchas mot transaktionen när den syns." repeated
it. The other answers keep their line: it names the liability the company
takes on.


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 18:07:01 +02:00
Jakob Wennberg c0eda46354 feat(mail): withhold new Gmail consents on hosted unless the company is allowlisted (#2320)
Every Gmail consent shows "Google hasn't verified this app" until the
restricted-scope review closes, and a prospect bounced on it today. Jakob's
call: remove the connector in the meantime rather than explain the screen.

New consents are gated by GOOGLE_MAIL_CONNECT_COMPANY_IDS on hosted: unset
means nobody (the default from this deploy on), `*` means everybody (set once
Google approves), a comma list means those companies (the reviewer's demo
company, the company the video is recorded in). Enforced in /oauth/start
(403 connect_disabled) and mirrored as connectEnabled on /connections, so the
settings page drops its connect button and the inbox start card falls back to
plain upload. Existing mailboxes stay listed, keep being searched and can be
disconnected. Self-hosted installs run their own Google app and are never
gated.


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 17:51:45 +02:00
Jakob Wennberg 3e5670f978 fix(expenses): "Vem betalade?" as a compact select in the Underlag rail (#2327)
The four two-line radio rows pushed the primary button below the fold in
the narrow rail. The question is now a select at the shared control
height with the chosen answer's one-line consequence under it; the list
keeps label + help per option and is pinned to the trigger width so it
wraps inside the rail instead of spilling over the document viewer.


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 17:27:12 +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
Mattsson f7f40c04e4 fix: return provider OAuth callbacks to the initiating brand domain (#2305)
* fix: hand provider OAuth callbacks back to initiating brand

* fix: encrypt provider OAuth handoff payloads

* fix: purge expired provider OAuth handoffs
2026-09-05 13:35:11 +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
Mattsson 1e1e1d5d17 fix(enable-banking): connector-hop failures no longer park a connection in error (#2296)
* fix(enable-banking): connector-hop failures no longer park a connection in error

On 2026-09-04 the daily cron flipped four canary companies (bank sync routed
through Accounted Connect, PR #2205) to 'error' with "Banksynkningen
misslyckades ... forny anslutningen" because the service answered 200 with a
body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users
re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected
shape" message left the contract mismatch undiagnosable.

- New ConnectorSyncError (status, code, body, Zod issue paths) thrown for
  every connector-hop failure: transport, timeout, error envelope other than
  a dead session, and wire-contract mismatch. The failing field paths are
  logged at the throw site.
- Cron: AspspUnavailableError and ConnectorSyncError are transient. The row
  is left untouched (no status flip, no renewal advice) and logged at warn
  level; the health probe on the same run still catches a dead session.
- Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer
  retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that
  the connection does not need renewing.

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

* fix(enable-banking): keep the connector response body out of the sync log line

Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw
connector body next to user and connection ids. A connector response can
carry transaction and personal data, so the log line now carries only code,
status, the Zod issue paths and the body length. The BAD_SHAPE message no
longer falls back to the body either.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:28:27 +02:00
Mattsson abaab1bf8d fix(settings): show series names in the verifikationsserie per typ picker (#2295)
* fix(settings): show series names in the verifikationsserie per typ picker

The "Verifikationsserier per typ" selects offered bare letters A to Z while
the manual verifikat dialog and the per-bankkonto picker already show the
letter with its Swedish name. Use the same closed preset list here, keep any
letter the company already uses so no saved value drops out, and correct the
help text, which named series that contradicted the presets.

Reported by a byrå partner on 2026-09-03.

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

* fix(settings): keep saved non-preset series selectable and name the convention

Seed the extras from the saved per-type map as well as the draft, so a
non-preset letter stays in the list after the user changes that row away
from it (a misclick could otherwise not be undone without a reload) and a
letter that lands in settings after mount is offered. Tie the help text to
the Fortnox convention instead of claiming general Swedish practice: the
SIE-world convention differs and the letter is a free choice.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:33:48 +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