1071 Commits

Author SHA1 Message Date
Mattsson a84d2723e0 feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import

A verifikat migrated from Fortnox/Visma lost the trail of what had been
corrected in the source system: the parser skipped #BTRANS (struck lines)
and #RTRANS (lines added by a rättelse) and nothing else read them. The
final state is still built from #TRANS only, exactly as SIE 4B prescribes
(#RTRANS is always twinned by an identical #TRANS, so summing all three
double-counts, #63). The two history record types now ride along the
voucher as `corrections` and land, inside the same atomic import
transaction, as one journal_entry_rattelse_log row per corrected voucher
with source='sie_import', the file's sie_import_id and the SIE `sign`
(who corrected in the source system; SIE carries who, never when).

Why the problem occurred: the March fix for double-counting chose "skip"
over "keep aside" because nowhere existed to keep the history. The inline
rättelse log (July) created that place, and every reader of it (verifikat
page, "Rättad" marker, behandlingshistorik, full archive) already renders
struck/added snapshots, so the history now flows through one table.

What was removed or simplified instead: no new table, no per-import
toggle, no fifth RPC parameter (sie_import_id travels inside each payload
entry so the (uuid,uuid,uuid,jsonb) signature, grants and
statement_timeout stay put and PostgREST sees no overload). The parser's
three identical TRANS/RTRANS/BTRANS field parsers collapsed into one
helper; the TRANS-only ledger path is byte-for-byte the same.

Why this over the proposed shape: the reporter suggested an own table or
column. A separate store would need its own readers, RLS, archive
classification and behandlingshistorik wiring; the rättelselogg already
has all four. Storing history in sie_imports.migration_documentation was
rejected as aggregate JSON that no per-verifikat surface reads.
Import-sourced log rows survive undo/replace like every other log row
(no FK on purpose); a re-import writes fresh rows against fresh entry ids.

Parser also warns when an #RTRANS is not followed by its identical #TRANS
twin (a spec violation that would silently drop a line from the final
state) and the record-type comments now match the spec wording.

Migration 20260909132618: three nullable/defaulted columns + CHECKs on
journal_entry_rattelse_log, sie_correction_snapshots() helper,
import_sie_journal_entries body verbatim plus the history insert. No
backfill; existing imports and log rows untouched.

Fixes #2427

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

* fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures

Review findings on PR #2458, one pass:

- Superagent P2: import_sie_journal_entries stored the caller-supplied
  sieImportId as WORM audit provenance without checking it. The RPC now
  requires the id to be one of the importing company's own sie_imports
  rows and fails closed (42501, whole import rolled back) on a foreign
  or fabricated id. pg-real test added.
- Compliance review: the voucher-level external_signature collapsed
  distinct correctors per line. Each struck/added snapshot now carries
  its own SIE sign (importer + sie_correction_snapshots), the summary
  column stays as the first one.
- Compliance review: created_at on imported rows is the import moment.
  Behandlingshistoriken now says so in the event details instead of
  leaving it implicit (the verifikat page already avoided a date).

Migration file is unshipped (not on main); staging re-applied under the
same version.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:45:29 +02:00
Mattsson 9a8291f454 feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457)
* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it

Resultatrapport and dimension-pnl filtered account 8999 out and printed a
computed result row, so a user who books or imports the omföring of årets
resultat by hand saw huvudboken and the account-level report disagree.

Why it occurred: the filter was copied from the formal Resultaträkning,
where it is right (ÅRL's uppställningsform has no 8999 line). In the
operational report it hid a real balance. Our own bokslut verifikat never
posts 8999 (it zeroes each P&L account straight against 2099), so the only
8999 balances that exist are manual or SIE-imported ones, exactly the case
the report suppressed.

What was removed: the exclusion itself, in both operational reports, so
they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat"
to match the UI and PDF. Beräknat resultat now reads zero after such an
omföring, the Fortnox/Visma resultatrapport convention.

Why this and not the proposed shape: the user asked about Resultaträkning,
which stays as is on purpose. The bigger version (Stage 2 of #1051, showing
the bokslut verifikat via exclude-final) would zero every row of a closed
year given our closing-entry shape and is a separate decision; recorded in
DECISIONS.md.

Fixes #2455

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

* test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning

The cross-surface agreement test claimed the two operational reports are
identical; after #2455 they differ by exactly a booked 8999 omföring, and
the fixture had no such row so the invariant went silently false. Pin the
gap explicitly, and note in DECISIONS.md that this supersedes the
2026-07-29 same-profit line.

Refs #2455

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 13:57:38 +02:00
Mattsson e996d70955 feat(settings): rename learned counterparty templates (#2454)
* feat(settings): rename learned counterparty templates

A user asked why a learned template under Inställningar > Mallar can be
deleted but not renamed. Nothing legal or ledger-shaped blocks a rename;
the one real obstacle was that the learn path keys templates by the
normalized bank description, so a renamed row would stop receiving
re-approvals and a duplicate would appear under the old key.

Why it occurred: counterparty_name doubles as display name and as the
learn/upsert key, and the only write path for it was the learner. There
was no rename because every later approval would have forked the row.

What was simplified instead of added: no display-label column, no new
table, no migration. The rename moves the old key into
counterparty_aliases, which the matcher already checks first, and the
learn lookup (findTemplateByKey) now resolves name-then-alias so
re-approvals and SIE re-imports land on the renamed row.

Why this over the proposed shape: a separate label would have kept the
key untouched but added a second name field for users to reason about;
renaming the key with an alias trail gives the user exactly what they
asked for with one fewer concept. Duplicate names are refused with 409
(active twin) or the invisible soft-deleted twin is removed (inactive).

Fixes #2453

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

* fix(bookkeeping): resolve the normalized-name match tier through aliases after a rename

Skeptic refutation on 819894559: the alias tier compares raw lowercased
bank descriptors, so the normalized key a rename pushes into aliases
("spotify") never matched there, and the name tier only knew the new
label ("musik"). A renamed template kept learning through
findTemplateByKey but was never proposed again for the merchant it was
learned from. nameMap now also resolves aliases, with a real
counterparty_name always winning over another row's alias.

Refs #2453

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

* fix(bookkeeping): canonical name beats a borrowed alias; unique-name race returns 409

Review findings on #2454:
- The alias tier ran before the name tier, so a bank line that is exactly
  another template's canonical name could resolve to a row holding that
  string as a rename alias. Aliases claimed by a different template's
  counterparty_name are now skipped when building the alias map.
- The PATCH twin check and the update are separate statements; a learn
  or a concurrent rename between them surfaced as 500. Postgres 23505 on
  the update now maps to the same 409 as the pre-check.

Refs #2453

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 12:32:29 +02:00
Jakob Wennberg bb28968151 fix(import): chunk SIE account creation and close the import row on every exit (#2451)
* fix(import): chunk SIE account creation and close the import row on every exit

A full-BAS Bokio SIE file creates 1 200+ chart_of_accounts rows in one
INSERT. PostgREST runs it under the authenticated role's 8 s
statement_timeout, and with four row-level triggers plus the RLS WITH
CHECK that single statement measured 6.5 s to 8.2 s on prod: it was
cancelled for one company and passed for the next (2026-09-09).

- syncMappedAccounts inserts in chunks of 100 rows (INSERT_CHUNK_SIZE),
  so every statement stays an order of magnitude inside the limit. A
  failed chunk leaves the earlier ones committed; the next attempt reads
  the chart again and inserts only what is still missing.
- executeSIEImport closes its pending sie_imports row in a finally
  block. Every early `return result` after createPendingImportRecord
  (account sync failure, missing fiscal year, overlapping import,
  vouchers outside the year) used to leave the row 'pending'. That row
  holds the (company_id, file_hash) slot in the partial unique index,
  so a retry inside the five-minute cleanup gate failed on the index
  instead of on the real error.
- The slot-held message no longer names "gnubok", an "Ersätt import"
  button the import history has never had, or Fortnox; it says the
  same file is being imported or was interrupted moments ago and to
  retry in a few minutes. The thrown-error prefix is Swedish
  ("Importen misslyckades:") and the two undo hints name
  accounted_undo_sie_import.

Tests: chunk sizes and first-failing-chunk behaviour in
account-sync.test.ts; failed-row finalize on two early exits and the
new slot message in sie-import.account-names.test.ts.

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

* fix(import): write account chunks as ignore-duplicates upserts with an exact count

Review follow-up on #2451. A plain INSERT per chunk still had the old
race: a concurrent import (or the replace flow) creating one account
between our read and write raised a duplicate-key error that the code
swallowed as success, while PostgREST had rolled back the whole chunk,
so every other account in it was silently missing.

Each chunk is now an upsert with onConflict (company_id, account_number)
and ignoreDuplicates, selecting the landed rows: the race becomes a
skipped row, `created` counts exactly what was written (also across a
mid-loop failure, which the compliance review flagged), and the
"duplicate" string special-case is gone.

Tests: conflict-skipped row not counted; a race yields no error; a
failing later chunk reports the rows the earlier chunks committed.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 12:24:29 +02:00
Mattsson fc2d78a7c4 feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)
* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick

Most people do not know their organisationsnummer. They left the
onboarding for allabolag, searched their company name there, copied the
number and pasted it back. #2421 let the field take a name, but only on
Enter and behind a screen that still said "organisationsnummer", so the
detour stayed. Now the field suggests companies while a name is typed
(name, orgnr or "Enskild firma", city; arrow keys or click to pick), the
pick fills the company like a typed orgnr, and the screen says "Vilket
företag är det?" with "Företagsnamn eller organisationsnummer" as the
placeholder.

Why the problem occurred: the one identifier the step asked for is the one
the user is least likely to remember, and the free-text path added in
#2421 was invisible (copy unchanged) and had to be guessed (Enter only),
because the only search index behind it was TIC, whose Lens budget cannot
take a call per keystroke.

What was removed or simplified: nothing is stored and no new state model:
a picked suggestion is an ORG_SUBMITTED with prefill, so the existing
LOOKUP_RESULT transitions (found, not found, disabled, error) decide the
step exactly as for a typed number. SCB's name search already existed for
the parties picker; it gained one option (sole traders) instead of a
second client. No rate limiting anywhere, per the founder.

Why this shape: SCB's företagsregister is free and already configured for
the parties picker, so search-as-you-type costs nothing while typing; TIC
runs once, on the pick, as it always did on Enter. TIC per keystroke was
rejected (3000/month). SCB alone was rejected for the pick because it
knows no F-skatt, VAT registration or fiscal year. The Enter path and the
chip row from #2421 stay as the fallback when no row is picked. Sole
traders are offered (they are half the users) but their row names the
form and never prints the personnummer, and the field shows the company
name after a pick for the same reason.

Changes:
- app/api/company/search: GET ?q= over the SCB client with sole traders
  included, top 6 rows plus a truncated flag; requireAuth() (no company
  yet), 400 for short or numeric q, 503 without SCB credentials, 502 when
  SCB does not answer.
- lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }),
  legalFormCode on every candidate; the parties picker is unchanged.
- lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX,
  fetchCompanySuggestions (503 is disabled, everything else error, never
  throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC
  vocabulary mapSetupEntityType reads).
- lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and
  form as prefill, lookupPending; lookupRan stays false until TIC answers).
- components/onboarding/journey: 300 ms debounced SCB search with abort of
  the superseded request, listbox under the field (combobox ARIA, arrow
  keys, Escape, Enter picks the highlighted row, otherwise the Enter path),
  copy switches with companySearchEnabled or ticEnabled; both journey
  pages pass isScbConfigured().
- messages sv+en: five strings.

Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a
sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every
outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC,
TIC overrides prefill, TIC off keeps the AB past form and name, unmapped
form falls to the picker, sole trader confirms the name, replaces a
previous orgnr, ignored off-step); SCB client sole-trader option.

Fixes #2448

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

* fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c)

Three independent refuters on the frozen commit; every refutation that
stood is fixed here.

- The listbox was position: absolute inside the field, but the step
  scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to
  the first row and mouse picks were unreachable (measured in headless
  Chrome). It now renders in flow under the field, where the chip row
  from #2421 already lives.
- After Enter on a name (the #2421 path), SEARCH_RESULT flipped
  lookupPending back and the debounced effect refetched SCB, laying the
  listbox over the chip row or next to the nomatch note. The effect is
  now quiet while searchHits is non-empty and for text the user already
  confirmed (Enter or a pick), until the text changes.
- The "many matches, type more" hint only rendered inside the list, so
  the flood case (SCB counts over 100 rows and sends none) showed
  nothing. The hint now renders on its own for that case.
- app/companies/new-client (byrå adds a client) renders the same journey
  and now passes companySearchEnabled like the other two pages.
- A stale mouse highlight could commit a row from the previous text on
  Enter: typing resets the highlight.
- Any 503 switched the picker off for the session; only the route's own
  SCB_NOT_CONFIGURED does now.
- NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the
  abandoned pick's name and form, which a later TIC error path would
  have written into the company. Both now drop name and form too, unless
  they came from BankID's CompanyRoles prefill, which is not about the
  number.

Not changed, recorded: a sole trader picked from SCB whom TIC does not
know lands on the "no company on that number" step with the name in the
field; the flow continues with the SCB name prefilled. The search JSON
carries the personnummer of sole-trader rows to the authenticated
browser (the row prints "Enskild firma"), same class as #2421's Enter
search; flagged to the founder.

Refs #2448

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:43:14 +02:00
Mattsson 6ea92f3152 feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed).

Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw.
What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body.
Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows.

Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
2026-09-09 11:19:39 +02:00
Jakob Wennberg 500806f001 fix(agent): the assistant reaches earlier räkenskapsår: period resolved from dates, years listed in the grounding (#2436)
Part 3 of #2185. A user with several imported years concluded the assistant
"only reads the period I am standing in". Nothing restricted it: the report
tools defaulted to the most recent fiscal period when no period_id was given
and then rejected any from_date/to_date/as_of_date outside it, and neither
the snapshot nor the chat identity block told the model which years existed
or how to address them.

- extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date
  hint; without period_id, a date in the call resolves the fiscal period that
  contains it (findFiscalPeriodContaining, company-scoped), and a date no
  period covers fails with the company's span instead of the latest year's
  bounds. Income statement (from_date or to_date), balance sheet (as_of_date)
  and dimension P&L (to_date) use it. The range guard is unchanged. No schema
  change: tools/list sits at its token ceiling.
- lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste
  först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on
  addressing an earlier year, shared by the single-call snapshot and the
  streaming chat's always-on identity block.
- lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's
  period_id, one call per year, and say which räkenskapsår the answer covers.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:32:38 +02:00
Jakob Wennberg 20925f6c65 feat(worklist): the next Skatteverket payment with bankgiro, OCR and due date under Att göra on Hem (#2435)
Part (a) of #2187. A twelfth worklist category, skattekonto_payment_due:
the earliest upcoming skattekonto charge whose sum exceeds the last synced
saldo, computed once in lib/worklist (server-side twin of the /skattekonto
page's Nästa dragning math) and rendered as one Betala row on Hem with the
shortfall, bankgiro 5050-1055, the OCR reference and the due date.

The row appears only when money has to move: a saldo that covers the charge
yields nothing, and no upcoming charge yields nothing. Ignored rows take
part, since Skatteverket draws them regardless of our flag. Without a
balance snapshot the full charge is the amount. Without an org number the
row keeps its bankgiro and date and drops the OCR.

No table, route or migration: /api/worklist/counts picks the category up
through getWorklistCounts, and Hem passes the computed row into the same
options wave as the expense payouts.

Part (b), a betalfil for the skattekonto payment, stays a follow-up: the
existing payment-file route is AGI-scoped.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:25:01 +02:00
Jakob Wennberg a7dcaac6ad feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)
The KPI payload has carried income, expenses and net per month since the
aggregates RPC, but after the Recharts trend chart was dropped only the net
column was rendered (the bars pane). A fiscal year's month-by-month sums
were therefore fetched and never shown (#2196).

- New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad,
  Intakter, Kostnader, Resultat) with the period totals as the last row,
  rendered between the panes and the cost story. Rows and totals come from
  the pure helper components/kpi/months-table.ts.
- New preference showMonthlyTable (default true) on KPIPreferences: filled
  by mergeWithDefaults on read, accepted by the preferences route, sent
  whole by the dialog, required by readPreferencesBody. A boolean, not a
  KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every
  existing company.
- One Switch row in the Anpassa dialog after the KPI list.
- Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total
  and the two settings keys in sv and en.
- Tests: helper rows/totals/inactive flags, defaults + merge, route accepts
  false and rejects a string; fixtures updated for the new field.

Closes #2196


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:19:19 +02:00
Jakob Wennberg d5373cd66c feat(invoices): ROT/RUT begäran status as a column and filter in the invoice list (#2434)
An invoice's begäran state (Att begära, Skapad, Uppladdad, Beviljad, Delvis
beviljad, Avslagen) was only visible one invoice at a time or inside the
payout dialog. The list now embeds the begäran behind each invoice, shows
the state in a ROT/RUT column and filters on it through a third
ContextPicker (?rotrut=), both gated on rot_rut_enabled or an invoiced
deduction. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the
column, the filter and its counts. Normal states read as muted text; only
a partial approval and an avslag get a chip.

Closes #2426


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:56:52 +02:00
Mattsson 9782f80db0 feat(invoices): offert to kundorder, the missing step in offert, order, faktura (#2442)
* feat(invoices): offert to kundorder, the missing step in offert, order, faktura

"Skapa order" on an open or accepted quote creates a draft kundorder from
its lines. The quote stays as the customer's accepted agreement (flips to
quote_status accepted with a compare-and-set on the decision that was
read); the order is delivered and invoiced, in full or in parts, from the
kundorder page. Declined quotes are refused. Same action on the MCP side:
gnubok_convert_invoice takes target 'order', staged under the existing
convert_invoice operation type.

Why the problem occurred: the proforma -> order conversion refused every
source that was not a proforma, so the offert, which is what users
actually send before an order, could only become an invoice. The product
had both ends of the Fortnox flow (offert, kundorder) but no bridge.

What was removed or simplified: no second service and no new operation
type. The proforma conversion became the document conversion
(lib/sales-orders/convert-to-sales-order.ts) with the quote source as a
branch on the source update, mirroring how convertToInvoice already
treats the two. The MCP surface is one tool with a target parameter
rather than a sibling tool, which also gives proforma -> order the MCP
surface it did not have.

Why this shape: the sale must never exist twice. A quote with a live
converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED),
and a quote with a live kundorder cannot become an invoice a second time
(new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A
cancelled order or invoice frees the quote again. Rejected: cancelling the
quote like the proforma path (hides the accepted agreement), a separate
gnubok_convert_quote_to_order tool, and refusing expired quotes (the
invoice path allows them behind a confirm; the order path does the same).

Fixes #2224

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

* fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate

Skeptic refutations on the offert -> kundorder change:

1. An already-accepted quote could be converted twice concurrently (two
   orders, or an order and an invoice): the services' pre-checks are not
   serialized and the accepted -> accepted compare-and-set matches for
   every caller. Migration 20260908152555 adds a partial unique index
   (one live kundorder per source document) and two BEFORE triggers that
   lock the quote row and refuse a live order beside a live converted
   invoice and vice versa, so concurrent conversions queue and the second
   one sees the first. The services map the raised codes onto the same
   409s the pre-checks use. pg-real test covers the index, both
   directions, reopen from cancelled, the member-session lock, and the
   concurrent pair on two connections.

2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a
   NULL exchange rate when Riksbanken had none, which resolveSekAmount()
   then posts 1:1 as kronor. Pre-existing, but the quote now depends on
   the order path and the fail-closed quote -> invoice route is refused
   while an order lives. The order path now fails closed with
   SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice.

Refs #2224

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

* fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order

The approval dialog's consequence sentence was keyed on operation_type
alone and promised a faktura with F-number for every convert_invoice.
With target 'order' the commit creates a draft kundorder and books
nothing, so the sentence now reads the params (skeptic refutation).

Refs #2224

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

* fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page

Correctness skeptic refutations on the offert -> kundorder change:

1. A quote with a live kundorder could still be set to open or declined
   (dashboard route, v1, MCP): the decision guard only knew converted
   invoices. The dashboard then hid the re-accept button, so the quote
   was stuck as "Avböjd" behind a confirmed, invoiced order. Migration
   20260908155231 extends invoices_quote_decision_guard to refuse leaving
   accepted while a live kundorder points at the quote
   (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code.

2. The two source guards from 20260908152555 locked the quote row with a
   SELECT FOR UPDATE as the invoker. Under RLS that also applies the
   UPDATE policy, which admits only the caller's active company, so a
   multi-company member writing for another company through raw
   PostgREST got no row, no lock and no guard. All three guard functions
   are now SECURITY DEFINER. pg-real test covers the non-active company
   and the decision lock.

3. The kundorder page labelled every source "Proformafaktura". It now
   loads the source document and shows "Offert OF-nnn" for a quote; the
   MCP field description and the type comment say proforma or quote.

Refs #2224

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

* fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards

CI: the target parameter and two description edits pushed the projected
tools/list payload to 60 502 tokens against the 60 500 ceiling; the same
facts now fit in fewer words (ceiling unchanged).

Superagent P2: the source guards run as definer since 20260908155231, so
a source_invoice_id or converted_from_id pointing at another company's
document would have locked and inspected that row. Both guards now
require the source to belong to the row's company and refuse otherwise
(SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH),
covered by a cross-company pg-real case. Migration 20260908155231 was
re-applied to staging under the same version (never on prod).

Refs #2224

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

* chore(migrations): move the quote conversion guards to versions after main's 20260908164944

Main merged a later version while this branch was open; Supabase applies
pending versions in order, so both files are renamed to fresh versions
(20260908165000, 20260908165100) and re-tracked on staging under those.
Byte-identical SQL.

Refs #2224

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:29:37 +02:00
Mattsson 32721b9f61 feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)
* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner

Why the problem occurred: the draft marking was a boxed yellow banner in
the page's top margin. It stayed out of the flow (#2369) but still read as
UI chrome pasted on a document, and carried a two-line legal sentence that
nobody reads on a preview.

What was removed: the banner block, its three styles and the four legal
sentences (sv+en). The draft state is now one word, bold, rotated -35deg at
14% opacity, centred on every page, the way a stamp marks paper. The
download dialog (#2399) already explains why a draft is not a valid
invoice before the file exists, so the PDF does not repeat it.

Why this shape: rotation and opacity sit on a padded wrapper View so the
word turns about its own centre and the Text keeps a plain type style.
The overlay is absolutely positioned over the page box and `fixed`, so the
document underneath previews pixel-identical to the final print; a test
asserts the first row sits at the same y as on a sent invoice. BETALD and
MAKULERAD banners are unchanged (separate concern).

Fixes #2437

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

* fix(invoices): darken the draft watermark so it survives a greyscale print

Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on
white, which a monochrome print or greyscale scan drops, and a numbered
draft otherwise prints the FAKTURA title, its number and an OCR like an
issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test
pinning the composited grey between 70% and 85% so neither extreme can
creep back in.

Refs #2437

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

* fix(invoices): paint the draft watermark last so opaque boxes cannot cover it

Skeptic refutation (correctness and regression, independently): the
overlay was the first child of the Page. react-pdf paints children in
document order and `fixed` does not hoist, so the customer box and the
full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the
word. On a two-page draft the last page, the one with totals, bankgiro
and OCR, lost the word entirely.

The overlay is now the last child of the Page, behind a single
isDraftMarked flag that also keeps the cancelled > draft > paid banner
precedence. A new test inflates the rendered PDF content streams and
asserts the UTKAST glyph run comes after the last rectangle fill on
every page, so the element tree alone can no longer pass while the
paint order is wrong.

Refs #2437

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

* docs(invoices): note the English DRAFT label in the download-decision comment

CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an
English document says DRAFT.

Refs #2437

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:11:43 +02:00
Mattsson 2d49a81508 fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)
* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts

A supplier-invoice item with a negative line_total (an öresavrundning row on
3740, a rabatt row) was copied straight into debit_amount, producing a line
like "3740 debit -0.25". The entry balances arithmetically, so no trigger
fired, but the verifikat page renders only positive amounts: the row showed
empty and the visible debits (20 056,25) disagreed with the summa (20 056,00).
Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1
customer invoice (3004 credit -0.50), 1 storno mirroring a bad original.

Why it occurred: the "one non-negative side per line" invariant lived
nowhere. Zod allows negative items (they are legitimate), the engine only
checked balance, and journal_entry_lines had no CHECK. Any producer that
aggregates user rows could repeat it.

What was removed or simplified: no new state. The privately-paid supplier
path already flipped negative buckets to credit; that rule is now one
helper (lib/bookkeeping/line-side.ts) shared by the supplier registration,
cash-method and privately-paid generators and by the customer-invoice
per-rate generator. The credit-note generator stops swapping sides and
takes |net|, since its inputs now arrive on the correct side.

Why this and not the proposed fix: patching only the supplier generator
leaves MCP, templates and future producers free to repeat the class, and
rejecting negative items at input would break real rabatt/avrundning rows.
So the sign is fixed at three levels: producers flip the side, the engine
refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT,
Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new
rows regardless of the writer. reverseEntry swaps on the net so a legacy
negative line stornos into a well-formed line before the data repair runs.

The 14 existing prod lines are repaired by a separate founder-approved SQL
(flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows
in a later migration once prod reports zero offending rows.

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

* fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side

Skeptic findings on ab119d6ed:

1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a
   separate revenue account) now lands that row on the debit side, but the
   1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount,
   so the entry was overstated by the row and threw "Verifikationen
   balanserar inte". Both anchors now use credit - debit. EUR test added for
   both paths.

2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets
   copied bucket.amount into debit_amount and the aggregated liability into
   credit_amount. A negative month (unpaid leave beyond gross) produced
   7510 D -628,40, which the engine now refuses. Buckets and liabilities go
   through debitNatural/creditNatural so a negative month books 7510 K /
   2731 D. Test added.

3. replaceOpeningBalanceEntry, the third engine write path, now runs the
   same non-negative guard as createDraftEntry and updateDraftEntry.

4. The credit-note comment claimed |net| is side-correct for every original;
   it is not for originals with a negative row (pre-existing, callers negate
   items with -Math.abs). Comment now states the actual behaviour and the
   known gap.

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

* fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero

CodeRabbit on #2439:

- JournalLineNegativeAmountError was not registered in isBookkeepingError /
  bookkeepingErrorResponse, so the journal-entry routes would have returned
  a generic 500 instead of the structured 400 with code and details. Added,
  with a test.

- The three supplier balance anchors (2440 on registration, the payment
  account under kontantmetoden, the liability account for privately paid
  invoices) were fixed-credit lines. An invoice whose rows net below zero
  (a leverantörskreditfaktura keyed in as an invoice) produced a negative
  credit there, which the engine now refuses. The anchors go through
  creditNatural so such an invoice books 2440 D, as a supplier credit note
  would. Tests for all three paths.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:10:34 +02:00
Mattsson 2f787a2b3c fix(bookkeeping): send each missing-underlag lookup chunk once per URL (#2430)
* fix(bookkeeping): send each missing-underlag lookup chunk once per URL

The "verifikat utan underlag" filter (/bookkeeping?missingUnderlag=true)
failed with "Verifikaten kunde inte hamtas" on a self-hosted instance as
soon as the candidate set passed one chunk of 150 ids.

Why it occurred: resolveMissingUnderlagEntries issues four lookups per
chunk. Three carry the id list once; the supplier_invoices lookup
interpolated the same chunk twice into a single .or() over
registration_journal_entry_id and payment_journal_entry_id. That URL
alone crossed the 8 KB header buffer nginx/Kong ship with, so the
gateway answered 414 before PostgREST saw the request. Hosted sits at
roughly half of Cloudflare's 16 KB ceiling on the same query. The
LOOKUP_CHUNK docblock acknowledged the doubling without sizing for it.

What was removed: the runtime-built .or() string, the chunkInList
helper and its uuid guard (the .in() array filter is injection-safe on
its own). The supplier-invoice lookup is now two .in() queries, one per
FK column, merged into the same set, so every request carries the chunk
exactly once and the proxy limit stops being a dependency rather than
moving. LOOKUP_CHUNK stays 150 and its comment is now true. The
literal filter also leaves the phantom-column scanner's unresolvable
budget.

Two secondary defects from the same report: MissingUnderlagQueryError
now carries the raw driver error as `cause` and the journal-entries
route logs it, while the response keeps the Swedish text (the log used
to say only "Nagot gick fel", hiding the 414). The "Visa saknade
underlag" badge renders the total of the last successful filtered fetch
and hides on failure, instead of borrowing the list count (0 on a
failed first load, the whole ledger after a toggle).

Alternatives: halving LOOKUP_CHUNK moves the wall instead of removing
it. Pushing the list filters into the verifikat_without_documents RPC
and deleting the TS mirror leaves one predicate instead of two, but
moves search, series, date and sort into SQL; recorded in DECISIONS.md
as the intended next step for the surface owner.

Fixes #2395

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

* fix(bookkeeping): hide the underlag badge on network failure, log the bulk route's cause

Skeptic findings on aacb17634. The badge contract is "no honest source,
no badge": the non-OK branch cleared missingCount but the network-level
catch (offline, aborted body, JSON parse rejection) did not, so a period
or series change that failed at that level kept the previous filtered
total next to the toggle. The bulk "Inget underlag kravs" route had the
same log gap as the list route: it returned the mapped text without
logging the driver error, so a gateway 414 on that path stayed
invisible.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:35:41 +02:00
Mattsson 26e29f47bc feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1)

Why the problem occurred: the legal form was modelled as a binary flag in
~300 files. `EntityType` was a two-member union, but nothing dispatched on it
exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize,
match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut,
MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B`
ternary. Widening the union compiled everywhere and changed nothing, so a
förening would have booked as an enskild firma in the app and as an
aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused
föreningar at the door (mapEntityType returned null), which is what the
tester hit.

What was removed or simplified: the silent defaults. One module,
lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser
(never defaults), the resolver (settings hint, then companies.entity_type,
then throw) and `byEntityType`, whose Record arms make the compiler refuse
the next widening until each site has an answer. The form-dependent facts
(closing account, owner settlement account, calendar-year lock, default
method, K1/K2 label, personnummer vs 16-prefix) live there once instead of
in the ternaries. On the SQL side supported_entity_types() replaces four
copies of the literal list in the create RPCs.

Why this shape and not the proposed one: the tracker asked for the enum
widening plus a chart; that alone was the dangerous version (compiles, books
wrong). Bundling stiftelse was considered and dropped: identical plumbing but
no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so
the CHECK, RPCs and seed can ship now and the first partner is switched on
without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut,
Swish) lands on the tracker.

Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before
Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner
accounts, member settlement on 2890; accrual default; brutet räkenskapsår
allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org
number gets the 16 prefix.

Migration 20260908110835 widens the three CHECK constraints, adds
supported_entity_types(), re-creates the three create RPCs with the widened
guard and adds the förening block to seed_chart_of_accounts. Applied to
staging and covered by ideell-forening-entity-type.pg.test.ts.

Part of #2072

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

* fix(company): close the förening paths the skeptic refuted (#2072)

Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the
shared definition rather than the reported site:

1. Privately paid supplier invoices and the utlägg dialog resolved the owner
   account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening
   member's invoice was built on 2893 and then refused by the expense-claim
   service (which already said 2890), burning an ankomstnummer. The helper now
   uses ownerSettlementAccount.
2. Booking templates substitute their `_ab` accounts only for an aktiebolag;
   the `private_expense` template kept its base 2013 for a förening. Template
   accounts now resolve through templateAccountForForm: EF base, AB override,
   förening base with owner accounts translated to 2890 (booking-templates.ts
   and proposal-lines.ts share it).
3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline:
   the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk
   person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule
   now keys on fiscalYearLockedToCalendar instead of the two literals; same in
   the MCP VAT report.
4. 2069 would have accumulated across years: the year-open omföring was
   AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the
   pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068)
   and skips forms with no carry (EF).
5. With the flag off, a registry lookup that returned "Ideell förening" was
   prefilled into the onboarding journey, the form picker was skipped and the
   create step answered "Ogiltig företagsform" with no way back. The
   journey, the BankID picker, the onboarding page and the MCP lookup now use
   mapSetupEntityType, which maps only creatable forms, so a flagged-off form
   falls through to the picker as before.

Also: form picker keeps its AB-first order; tests for each fix.

Part of #2072

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

* chore(migrations): move ideell förening migration after main's latest version (20260908143051)

Two migrations landed on main after the branch forked; a lower version
would be skipped by the merge-time apply. Staging history row renamed to
match.

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

* chore(skills): regenerate accounted-api reference for the widened entity_type enum

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:47:50 +02:00
Mattsson 1157ff1b66 feat(onboarding): the orgnr field also accepts a company name (#2421)
* feat(onboarding): the orgnr field also accepts a company name

The journey's first question kept asking for an organisationsnummer, and
people who do not know theirs by heart left to look it up. The same field
now takes either: digits (with dashes or spaces) run the existing orgnr
lookup unchanged; anything else with three or more characters runs a
free-text name search against the same TIC index. One hit continues
exactly as a typed orgnr would; several hits render as a chip row
"Name / orgnr / city" inside the same question, and the pick applies the
hit's already-fetched lookup result. No hits stays on the step with a note
to refine or type the number. The screen, placeholder and hint are
otherwise untouched; only the mobile keyboard changes from numeric to text.

Why the problem occurred: the lookup was keyed on the one identifier the
user is least likely to remember, while the provider index behind it is a
full-text index that already answers names.

What was removed or simplified: nothing new is stored. The TIC search
document carries every field /lookup returns, so a name hit is mapped by
the same mapper and a picked hit costs no second provider call. The reducer
gained one shared "TIC answered" transition (applyLookupFound) that the
typed-orgnr path, the single-hit path and the pick path all use, instead of
three copies of the fact-to-settings mapping.

Why this shape and not the proposed one: search-as-you-type autocomplete
would burn the 3000/mo TIC budget in days, so the search fires on Enter
only, like the orgnr lookup. Taking the top hit blind on several matches
was rejected: name ranking is fuzzy and common names or sole-trader
surnames would land on a stranger's company; a five-chip pick row is the
smallest thing that keeps the user in control. The route answers 400 under
three characters, 404 in the handler's own "Company not found" shape so the
client's existing dispatcher-vs-handler mapping applies, and every TIC
failure code maps through the same handler as /lookup.

Fixes #2418

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

* fix(onboarding): reduce Lens registration numbers to the 10-digit form for name-search hits

Skeptic pass on 1d70716a8 (issue #2418). A sole trader found by name got
Lens's 16-digit registration number (century-prefixed personnummer plus a
4-digit serial) stored as org_number; createCompany refuses anything
normalizeOrgNumber rejects, so the journey dead-ended at the last step and
the returned orgnr step could only shake. The typed-orgnr path never stored
Lens's number, so this was the first place it reached settings.

- searchCompaniesForLookup derives orgNumber through the new
  lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit
  enskild-firma form reduce to the 10-digit key; hits that do not
  normalize are dropped, never dead-ended).
- Sole-trader chips show "Enskild firma" and city instead of the number,
  which is the owner's personnummer.
- The name path resets the duplicate note on submit, so an earlier orgnr's
  "you already have X" no longer sits above the chip row.

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

* fix(onboarding): keep the typed name in the field after a search pick

Compliance swarm on PR #2421: writing the picked hit's org number into the
visible input printed a sole trader's personnummer in plain text on Back,
the one thing the chip row masks. The field now keeps the name the user
typed; Back re-searches it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:23:05 +02:00
Mattsson e85eac317c feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* feat(arsredovisning): manual override for medelantal anstallda (Not 2)

Why the problem occurred: the ÅRL 5:20 § note was derived only from the
employees table, and most aktiebolag that book salary never create a
Löner employee record (hand-booked salary, SIE import, migrated
history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have
no employees rows, so their note reads "inga anställda". The reporting
company had one row created the same day with a start date halfway
through a July-June year: 181/365 = 0.5 FTE rounds to 0.

What was removed or simplified: nothing removed. One resolver
(resolveMedelantalAnstallda: override, else FTE average) now feeds the
K2 note, the K3 note and the iXBRL fact, so no reader can pick a
different number. The override sits on arsredovisning_narratives next to
the other ÅRL 5 kap. disclosures and rides the existing narrative
GET/POST route, service and page save.

Why this and not the proposed one: the request asked support to "enable
override of Not 2" as free text. A whole number keeps the statutory
sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free
text would allow a non-compliant note. Rounding 0.5 up globally was
rejected (changes every company's note silently, does nothing for
companies with no employees rows), as was backdating the hire date
(fixes one company, misstates the fact).

The iXBRL input also reads the previous period's override so the
jämförelseår column shows what last year's document showed.

Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and
is applied and tracked on staging.

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

* fix(arsredovisning): size-threshold metrics use the medelantal override too

Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read
the FTE average from the employees table, so the ÅRL 1:3 § större-
företag test and the K2 relief thresholds could disagree with the figure
Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no
employees rows and an override of 60 both years, balansomslutning over
40 MSEK, would have validated as K2-eligible while its own document said
60 employees.

Fix: the metrics resolve the employee figure the same way the note does
(current period override from report.disclosures, previous period via
getMedelantalOverride on that period's narrative row). previous_period
on ArsredovisningData now carries the period id so the lookup needs no
extra fiscal_periods read. The iXBRL employees-error fallback keeps the
previous period's override instead of blanking the jämförelseår.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:44:29 +02:00
Mattsson 59d5b7b366 fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts (#2419)
* fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts

A sole trader booking a tractor activated 1240 and 1249 from the account
picker and got a machinery head labelled "(Fritt konto för Maskiner och
andra tekniska anläggningar)" next to a contra account labelled
"Ack. avskrivningar på bilar och andra transportmedel".

Why it occurred: BAS 2026 restructured kontogrupp 12. Bilar and datorer
moved under 1210 (för produktion) and 1220 (ej för produktion), and
1230/1240/1250/1260 became free heads. The catalog in
lib/bookkeeping/bas-data/ followed for the heads (#463) but kept seven
sub-accounts the official chart no longer has (1241, 1242, 1249, 1251,
1259, 1261, 1269) with their pre-2026 names. Every picker activation of a
12xx contra account therefore produced the contradiction; prod carries
the 1240/1249 pair in 173 charts, 1250/1259 in 153 and 1260/1269 in 78.

What was removed instead of patched: 1241, 1242, 1251 and 1261 leave the
catalog entirely (bas.se BAS 2026 v2 has no such accounts; the SIE
mapper already self-maps unknown sub-accounts by number). 1249/1259/1269
stay because the asset module's vehicle and computer defaults and 31 live
assets in prod depend on them; they are renamed after their heads so the
pair reads as one thing.

Why this and not the proposal: the reporter asked for 1249 to be renamed
to "ack. avskr. maskiner", which fixes one number and leaves 1259/1269
and the four retired asset accounts contradicting their heads. Dropping
1249/1259/1269 and moving the asset defaults to BAS 2026 (1226/1224 on
1229) is the right long-term shape but changes what a new vehicle or
computer asset books to; that decision is the founder's and is tracked
in #2414.

The migration renames a contra account only when its name is
byte-identical to one of the two catalog literals AND the company's head
carries the BAS 2026 free label, so old-BAS imports (1240 "Bilar och
andra transportmedel") and every user rename stay untouched. Applied and
pg-tested on staging.

Fixes #2413

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

* fix(bookkeeping): skip 12xx contra accounts with journal lines in the label backfill

Skeptic refutation: lib/import/account-sync.ts creates missing accounts
with the catalog name when the SIE #KONTO names are not carried, so an
old-BAS vehicle chart can hold the exact free-head + bilar-contra pair
with years of depreciation booked on 1249 (8 such charts in prod). The
backfill now also requires that the contra account has no journal lines:
a label with history is the user's to change. Migration re-issued under
a fresh version, applied and pg-tested on staging.

Refs #2413

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

* fix(bookkeeping): lock journal_entry_lines while the 12xx label backfill checks history

CodeRabbit (Major): under READ COMMITTED a posting could commit between
the NOT EXISTS history check and the rename. A SHARE lock on
journal_entry_lines for the migration transaction makes the two atomic;
inserts wait milliseconds, reads are unaffected. Migration re-issued
under a fresh version, applied and pg-tested on staging.

Refs #2413

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:34:50 +02:00
Mattsson 2303f75a7b fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage

Why the problem occurred: the supplier register was written in three
spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored
whatever the caller sent, the AI extractor emits bare digits) while
matchSupplierByIdentity compared raw strings with .eq(). The canonical rule
existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey,
the extractor's toOrg10) and nowhere on the path that decides a match, so
every AI-extracted invoice from a hyphen-registered supplier missed the
strongest key and fell to exact-name matching. Prod holds 1738 hyphenated
rows against 493 bare ones.

What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10
of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two
other copies. The matcher scans the company's suppliers with an org_number
and compares keys, the same shape as its vat_number branch, so rows written
before the backfill (and self-hosted instances that never run it) match too.
CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier
schema store the key; the form renders it through formatOrgNumberDisplay.
A backfill migration strips the formatting from existing rows, skipping
migration-reset source companies.

Why this and not the proposed one: the issue's third layer (CHECK plus a
unique index) would fail to create on prod, which holds 94 duplicate
(company_id, key) groups across 18 companies, one of them 124 rows under a
single placeholder-looking number; that needs a merge decision first and is
filed as #2404. Rejecting anything that is not 10 or 12 digits on write was
also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL,
FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is
canonicalised and anything else is stored as typed. Luhn stays lenient on
suppliers because two rows with the same mistyped number are one supplier
and parties is Luhn-strict at promotion already.

Fixes #2391

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

* fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key

Skeptic pass on the previous commit. Three refutations, all confirmed:

1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A
   VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to
   6012579001, another company's identity, on every write path and in the
   backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A
   Belgian BE0123456789 lost its country letters the same way. The key now
   strips only hyphens and spaces and unprefixes 12 digits only behind
   16/18/19/20; everything else is null, stored and compared as typed. The
   migration carries the same rule.
2. The supplier list search, the v1 ?search= filter and the list column all
   used the raw stored value, so a user searching 556677-88 after the
   backfill found nothing. Both searches now compare without separators and
   the column renders XXXXXX-XXXX.
3. Storage was not canonical on every path: the CSV import and the provider
   migration orchestrator wrote as typed and keyed their re-sync dedup by
   the raw value, so a Fortnox re-sync sending 556677-8899 would have
   duplicated the now-bare row. Both write and key through orgNumberKey.

Also: the matcher scans live suppliers only, so a register holding an
archived hyphenated row next to its live replacement resolves to the live
one instead of whichever id sorts first.

Refs #2391

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

* fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical

CodeRabbit findings on PR #2405, all verified against the code:

- The supplier list rendered through formatOrgNumber, which strips letters
  and would show BE0123456789 as 012345-6789; it now uses
  formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone.
- The CSV import dedup fell back to digits-only, so BE0123456789 and
  FR0123456789 collided; the fallback is now the value as typed, in both
  the parse preview and the execute route.
- The provider migration's supplier-invoice stub map was keyed by the raw
  provider value while the stored row was canonical, so 556677-8899 and
  5566778899 on two invoices produced two stubs; the key goes through
  orgMapKey like the other maps.
- v1 response examples show the stored 10-digit form; the request example
  keeps the hyphenated input.

Refs #2391

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

* docs(api-skill): regenerate suppliers reference for the canonical org_number example

Refs #2391

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:59:20 +02:00
Mattsson eea410c42b fix(bookkeeping): correctEntry moves bank anchors to the correction; deleting the correction returns them (#2406)
* fix(bookkeeping): correctEntry moves the original entry's voucher links to the correction

Why the problem occurred: a bank row has two anchors, the pointer column
(transactions.journal_entry_id) and the transaction_voucher_links junction
(bulk-book writes a bank_line row beside the pointer for N=1 and as the only
anchor for a samlingsverifikat with N>1). correctEntry re-pointed only the
pointer, so the reversed original kept its junction rows and every junction
reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC,
the reconciliation bridge) went on treating the row as anchored there. A
later storno of the correction released the pointer while the stale link kept
the row out of Att bokfora: the split #2061 fixed on the storno path,
reproduced on the correction path. Prod holds 7 such links in 3 companies.

What was removed or simplified: nothing new is added to the data model. The
relink helper now moves both anchors with the same predicate (company and
source entry), so the junction follows the pointer and one rule covers the
N=1, samlingsverifikat, 1:N slice and residual shapes. A relink failure is
surfaced on the result (transactionRelinkError) beside documentRelinkError
instead of being logged and forgotten.

Why this solution: the issue proposed deleting the original's junction rows.
For a samlingsverifikat the junction is the row's only anchor, so deleting it
would push rows the corrected verifikat still explains back into the
worklist; re-pointing keeps them booked against the live entry. A
relink_entry_anchors RPC moving pointer and junction atomically was
considered and left for later: it costs a migration plus pg test on a path
that is already best-effort across five other statements, and the surfaced
warning now makes a partial failure visible if one ever happens.

Tests: unit cases on correctEntry for the junction update, its scoping and
the surfaced warning; a pg-real suite that runs the two UPDATE statements as
the correcting user against real Postgres for the N=1, samlingsverifikat,
split-plus-residual and cross-tenant shapes (RLS, the writer-role gate and
the immutability triggers do not block the move; role and allocated_amount
survive it).

Fixes #2364

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

* fix(bookkeeping): deleting a correction returns its bank anchors to the original

Found by the skeptic on the previous commit. Once the junction follows the
correction, the two-step undo of a rattelse (delete the correction, last in
series, then delete the storno, which restores the original to posted)
cascaded the links away with the correction (FK ON DELETE CASCADE; the
pointer FK is ON DELETE SET NULL). The restored original then explained bank
rows nobody pointed at: is_business stayed true, the rows surfaced as
bookable in Att bokfora and in bank reconciliation, and a second booking of
the same movement was one click away. Before, the links had stayed on the
original by accident and the undo happened to be clean.

delete_last_voucher (migration 20260908095907) now moves both anchors back to
correction_of_id before deleting a correction, the inverse of the move
correctEntry makes. Releasing the rows instead would leave the same trap
(the restored original still explains them), and a TS pre-step in the
DELETE route is not atomic with the RPC's own guards. A link the original
already holds (a correction made before the junction followed it) is
dropped rather than duplicated. Everything else in the function is
byte-for-byte 20260528120600.

Applied to staging and recorded under the file's version. pg-real suite
covers the N=1, samlingsverifikat, pre-existing-duplicate and plain-voucher
shapes; the existing delete_last_voucher and document-immutability suites
still pass.

Refs #2364

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

* docs(decisions): record the #2364 prod repair as planned, not done

CodeRabbit on PR #2406: the entry read as if the seven-link repair had already
run. It runs after merge on the founder's go and gets its own dated entry.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:34:15 +02:00
Mattsson 4e8d649b2f fix(invoices): Ej skickade view and a prompt before downloading a draft-stamped PDF (#2401)
* fix(invoices): show finalized-but-unsent invoices as their own view and ask before downloading a draft-stamped PDF

Two user reports, one hidden state: an invoice that went through Granska
& skapa has an F-number but its DB status is still 'draft' until it is
marked as sent (and booked). The list lumped those rows under Utkast, and
"Ladda ner PDF" handed out the UTKAST-stamped render with no warning, which
users then mailed to customers.

Why it occurred: the status column carries two meanings for 'draft'
(unnumbered draft vs numbered, unsent invoice) and every surface decided on
its own how to read it. The list badge knew the difference ("Ej skickad"),
the tab predicate and the PDF download did not.

What was simplified: the tab predicate moved out of the page into
lib/invoices/invoice-list-tabs.ts as one function used by the rows, the
per-view counts, the status sections and the row badge, so the four cannot
drift. The ?status= alias parsing collapsed into the same module.

Why this and not the proposed shapes: a real 'issued' status in the DB
would touch MCP, the v1 API, reports and SIE for a distinction that
invoice_number already carries. Removing the UTKAST stamp from numbered
drafts would be wrong: an unbooked invoice is not issued. So the UI splits
the state (Ej skickade view, ?status=unsent, ?status=godkanda alias) and the
download asks first: "Bokför och ladda ner" (or "Markera som skickad och
ladda ner" for cash-method companies and offerter) runs the existing manual
mark-sent dialog and then downloads the issued document; "Ladda ner utkast"
still works. The manual mark-sent toast now offers "Ladda ner PDF" too.

Fixes #2399

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

* fix(invoices): skeptic round on the draft-download prompt

Four refutations on 352d4bafc, all confirmed in code:

- Proformas (and any non-faktura) in an accrual company were told "Fakturan
  är inte bokförd" and offered "Bokför och ladda ner"; mark-sent never books
  a proforma. The label predicate is now issuesByBooking = booksOnIssue &&
  isRealInvoice, shared with the existing primary button, which carried the
  same "och bokför" promise on proformas.
- A partial mark-sent success (PDF archive, periodisering or delivery history
  failed) still ran the chained download, and its toast evicted the warning
  (one toast at a time). onSuccess now carries `partial`; the chained
  download is dropped on a partial result, matching the toast action.
- Numbered följesedlar are stamped UTKAST too (pdf-template does not exclude
  them) but the decision skipped the prompt. They now get the prompt; the
  issue action is their own status flip, so updateStatus reports success and
  the download is queued only after it.
- The download queue was a boolean bound to "whatever invoice is mounted";
  the detail pager keeps the page mounted across ArrowLeft/ArrowRight, so a
  step could download the neighbour or leave the queue armed. The queue now
  holds the invoice id and is dropped when a different invoice is shown.

Refs #2399

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

* fix(invoices): review pass on PR #2401

CodeRabbit, all confirmed against the code:

- The PDF preview (Visa PDF) bypassed the draft prompt; the browser viewer
  has a save button, so an unwarned preview is an unwarned download. The
  preview now runs the same decision; the prompt's draft button honours the
  original intent ("Visa utkast" opens the viewer, "Ladda ner utkast" saves).
- updateStatus lost its isUpdating reset when both branches started
  returning; moved to finally so a failed refetch after mark-sent does not
  leave the page's buttons disabled.
- A cancelled credit note matched both the Kreditfakturor and Makulerade
  views; the credit view now excludes cancelled rows like every other view.

Declined: the DECISIONS.md date (2026-09-08 is the local date the decision
was recorded; the bot compared against UTC) and the docstring-coverage
warning (not a repo gate).

Refs #2399

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 01:10:14 +02:00
Mattsson 477b59453f fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string (#2398)
* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string

Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").

One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.

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

* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card

Skeptic findings on 4a30bb3f3:

- The repair migration would have aborted on prod: 110 of the 6,356 rows
  belong to a migration-reset source company, whose transactions are
  immutable by trigger (transactions_block_migration_reset_source_mutation).
  Same failure as 20260903170000. Those rows are now excluded; nothing reads
  the column back for an archived company.
- With the code description reaching the keyword tables as a string,
  "Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
  matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
  so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
  precedes withdrawal in both tables (and in the Connect mirror), matching
  what TRAILING_PHRASES already says about the same phrase.

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

* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed

coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:37:23 +02:00
Mattsson fdcb7d937e feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle

Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT.

- /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut,
  refused to book, ready to request) and one row per begaran with mark
  uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor
  button links here, ?rot-rut=1 still opens the file dialog.
- Beslutsfil import from the UI through the existing import route.
- Reclaim of the share Skatteverket refused: one voucher debit 1510 /
  credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to
  the begaran and guarded by a partial unique index; the invoice reopens
  for the refused share via invoices.deduction_reclaimed_total, with the
  customer-share formula and its SQL twin gaining the same term. The
  payment dialog and bank match then settle the reopened remaining as a
  plain 1510 clearing; a booked kontantmetod invoice is proposed accrual-
  shaped so revenue is never recognised twice. Unknown per-invoice split
  of a partial beslut is refused, never allocated.
- MCP: gnubok_list_rot_rut_payout_requests (search-only read) and
  gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout)
  sharing one pre-flight + settle with the dashboard match route.
- Migrations 20260907140000 (reclaim state, source_type, INSERT guard),
  20260907140100/140101 (pending_operations op type).

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

* chore(rot-rut): renumber migrations after merging main

Main already carries 20260907143000 and 20260907150000, so the three
rot-rut migrations move to 20260907160000/160100/160101 to keep the
applied order monotonic (see memory: migration-version-collisions).

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

* fix(rot-rut): close the reclaim gaps found by skeptics, CI and review

Skeptic refutations (#2397):
- payment-sync recomputes remaining with deduction_reclaimed_total, so a
  storno of a payment on a reopened invoice no longer strands the refused
  share (R1).
- Reclaim refused while an invoice sits in a later live begäran
  (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide
  the action for the same case (C2).
- A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED)
  until the reclaim voucher is reversed (R2/C3).
- Storno of the reclaim voucher syncs the invoices and the begäran back
  (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3).
- A paid invoice with NULL paid_amount counts its customer share as paid
  (C4). Crediting an invoice with a reclaimed share is refused on the
  dashboard, v1 and MCP paths (R4).

CI and review:
- Build: custom-coded MCP errors via Object.assign, not codedError.
- pg-real: column default for default_voucher_series_per_source_type
  re-stated with rot_rut_reclaim (20260907160200); the default test now
  re-applies the latest default migration.
- Checks: accounted-api skill regenerated (journal-entries source types).
- CodeRabbit/Superagent: per-item refused shares must reconcile with the
  request-level beslut; per-invoice reopen through the idempotent RPC
  apply_rot_rut_reclaim_invoice (20260907160300) with a resume path;
  update-stage settle failures keep the voucher id (failed_partial);
  Stockholm calendar date for the booking; existing-voucher tab uses the
  same proposal method; MCP stage checks bank_line junction rows.

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

* fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut

- The shared match outcome now declares journalEntryId on update-stage
  errors, matching the settle service (Core Build TS2339 on 2d6cece1a).
- The reclaim voucher is dated on the Swedish calendar day of Skatteverkets
  beslut (decided_at), today only when no decision date is recorded, and
  the confirm dialog states the date (Swedish accounting review: BFL 5 kap
  6-7 §, datum for affarshandelsen).

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

* fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column

- apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300
  signature) takes only the refused share, validates it against the locked
  item, request and invoice, and derives remaining_amount and status from
  the INSERT-guard formula (review: caller-supplied accounting values,
  CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed
  reclaim voucher; the request link is cleared only after every leg.
- v1 credit route projection includes deduction_reclaimed_total so the
  reclaim guard actually fires there.
- Overview keeps "Bokfor nekat belopp" available while legs are pending
  (resume after a partial failure).
- Match and settle routes attach journal_entry_id on update-stage errors.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:13:05 +02:00
Jakob Wennberg 5d735097a1 fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)
* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only

markPeriodClosedExternally decided "migrated" by asking whether the period
had any non-imported verifikat at all. That was a proxy for the thing the
guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap),
and it shut out the migrated first year whose only native voucher re-keys
the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE
import. With the next year's IB already imported, the normal year-end
refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all.

The guard now asks the ledger the real question: are there lines on result
accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk
head counts with early exit, no line fetch, no journal_entries!inner embed.
The refusal message names what was found and why the normal year-end is
the remedy.

The loaded årsredovisning view also gets the FyPicker in its header. The
no-period branch auto-jumps to the remembered scope (or the newest year)
before its own picker is ever seen, so a user whose scope pointed at the
historical year had no way to reach the current year's årsredovisning
except by changing the scope on some other page and coming back.

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

* fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8

Triage of the swedish-compliance review:

- Balance-sheet-only native years bypassing the bokslut: partly real. The
  result transfer is not the only thing the normal year-end does; it also
  posts the next year's IB. A natively bookkept year with only balance-
  sheet vouchers (dormant AB, aktiekapital deposit only) could have been
  klarmarkerad and its balances would never have reached the next year.
  The leg now additionally requires the next period to carry IB already,
  which is exactly the shape where the normal year-end refuses
  (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB
  the normal year-end works and stays the remedy, and the message says so.
  The suggested IB-correction tag on entries was not adopted: the ledger
  already answers the question, a tag would be one more concept to know.
- Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9
  interna poster no longer count as result accounts. Storage format is the
  4-digit BAS string every classifier in the codebase already relies on
  (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as
  those.

Tests: next year with IB passes, next year without IB refused, no next
year refused, lt('account_number', '9') asserted.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:10:41 +02:00
Mattsson f047c3d7d1 fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user (#2373)
* fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user

The Skatteverket OAuth callback answered NEXT_PUBLIC_APP_URL regardless of
where the flow started, so on a white-label brand domain the popup's
postMessage was dropped and the fallback redirect landed on the wrong
origin without a session. On hosted, the initiator check from #2155 was
bypassed by design because the registered callback host carries no app
cookies, so a lured victim's BankID-authorised tokens could be stored
under the user who started the flow.

Flow state moves from six per-company extension_data keys to one
oauth_flows row per flow (migration 20260907120000), consumed atomically.
Hop 1 on the registered OAuth host consumes the state, stashes the
provider code or error encrypted under a separate handoff id and 302s to
the recorded origin; hop 2 there claims the handoff bound to that origin,
requires the initiating user's session, re-checks membership and
exchanges the code. Error pages keep the tab open. The self-hosted
single-hop and the connector broker branch keep working. The hosted
no-session exception, the legacy cookie-user fallback and the optional
PKCE verifier are gone.

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

* fix(skatteverket): decide the callback hop by host, close the tab when the flow is unknown

Skeptic findings on #2373. The hop comparison and the handoff claim used
the request origin including its scheme, which Next derives from
x-forwarded-proto; a self-hosted proxy that forwards Host without it (or
rewrites Host to the upstream address) made every connect end in a state
error. Hops are now compared by host only, and the handoff is claimed for
the validated origin the host resolves to, scheme from configuration.

Error pages answered before the flow row is known (unknown, expired or
replayed state or handoff) post to a guessed origin that a brand opener
never hears; they now close the tab so the panels' closed-tab watcher
resets them instead of leaving Connect disabled.

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

* test(skatteverket): mock resolveBrandResultByHost for the merged login-redirect resolver

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

* fix(skatteverket): bind the initiator before the flow is spent

Superagent P2 on #2373: hop 2 deleted the handoff before the session and
membership checks, so a signed-out or wrong-user arrival burned a live
consent. The finishing hop now peeks the row for its initiator, binds the
completing session to it, and only then consumes atomically. A
session-less arrival is sent to /login on the initiating origin and
resumes into the same callback URL; a different user is refused with the
row left claimable for the initiator. The handoff TTL is five minutes so
a sign-in fits.

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

* fix(skatteverket): check membership before the flow is spent, answer the callback page on a failed mint

Second review cycle on #2373. Superagent: the company-membership check
ran after the consume, so a revoked initiator burned the provider code on
the way to being refused; it now runs inside the pre-consume binding.
CodeRabbit: a failed handoff mint escaped as a framework error page the
opener never hears; it now answers the callback error page on the
initiating origin.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:43:38 +02:00
Jakob Wennberg a769bc9d03 feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow

BL issued our integration activation key on 2026-09-07. With
BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i
Lundify": the customer logs in at Lundify, picks the company and accepts
the scopes, and Lundify returns the company's User-Key to our callback as
publicKey with our one-time state echoed as extra. The manual User-Key
field stays as a folded fallback for companies that activated inside
Lundify already.

The callback folds publicKey/extra into the OAuth-shaped locals, so the
atomic state consumption, initiator binding and white-label handoff run
unchanged; only the final step differs: submitProviderToken (the same
client-credentials probe as the manual field) instead of an OAuth code
exchange, owned by the consent's company read from the server-written row.
consumeOAuthState/consumeHandoff now return that company id.

Closes #2323.

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

* fix(migration): reset the previous connect attempt before a new provider request

Review follow-up: a failed /connect used to leave the earlier consent id
and one-time activation URL in place, so the step kept offering a link
that completed the previous consent.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:38:07 +02:00
Mattsson 8aa5d54510 chore(domains): add solbo.accounted.se to the production host inventory (#2378)
Solbo was provisioned on prod on 2026-09-01 (team ce8993e5, brand
6e3c78af) but never added to CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. The
namespace rule already protects the host; the set is the checked-in
inventory the tests pin host by host, and it was stale.


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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:25:34 +02:00
Mattsson d29a5bda14 fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

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

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

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

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:12:22 +02:00
Mattsson 7a30f623ba fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

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

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

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

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

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

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

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

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

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

* docs(invoices): state the bundled-font width margin accurately

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

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:45:38 +02:00
Mattsson c634cf9ae0 fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)
* fix(banking): return Enable Banking consent callbacks to the initiating brand host

Enable Banking redirects every consent to the one canonical callback URL
while browser sessions are per host, so a white-label user reached the
callback signed out and was bounced to the unbranded canonical login. The
pending row now records the allowlisted origin the flow started from, and
the callback uses it for the login bounce, the success redirect and the
denial banner. The brand host already holds the session, so its /login
forwards straight back into the callback with cookies; the provider
redirect URI stays canonical, nothing changes in the Enable Banking
console. The shared login redirect helper also stops dragging a callback
that arrived on a registered brand host to the canonical login.

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

* fix(banking): reload the PostgREST schema cache after adding oauth_origin

Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY,
and without it PostgREST can reject the new column on connect until its
cache refreshes.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:14:36 +02:00
Jakob Wennberg 0016732232 feat(invoices): let the invoice list read by row tone without the status column (#2353)
* feat(invoices): let the invoice list read by row tone without the status column

A user asked for green/yellow/red row tints in the fakturalista (#2215) so
the list can be read without the last column. The need is real; the tint
is not the answer the design system already gives: status colours are
data, not chrome (convention 12), and DECISIONS 2026-08-03 already ruled
out row tints for this list.

Settled rows (paid, cancelled, credited) now recede to muted text as a
whole row. Open rows keep the foreground and the overdue chip stays the
one marker on its row, so three row classes read at a glance with zero
new colour: grey is done, dark is waiting for money, dark with an ochre
chip is late. Works in dark mode and the brand themes for free and never
stacks with the hover or selection tints.

The tone lives in a pure lib helper (invoiceRowTone, tested) and one
Record in the page maps tone to class; a row tint or a left-edge bar is
a three-string swap there if the founder prefers colour.

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

* docs(decisions): record the first-principles choices of the 2026-09-06 evening issue batch

Ten lines for #2215 #2351 #2218 #2220 #2312 #2184 #2293 #2239 #2331 #2332
(PRs #2353 to #2362), carried by this PR so the other nine branches do not
all touch DECISIONS.md.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:24:28 +02:00
Jakob Wennberg bf7773d74b feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)
* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184)

A new company_settings row now defaults to the standard series set
(A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor,
E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura,
M moms) instead of everything on A. The set lives once, as the
exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test
holds the column default equal to it and to the source_type CHECK.

Existing rows are not remapped: the per-type settings form gets an
"Använd standarduppsättningen" action that fills the set for review
and save through the existing PUT, so the switch is a deliberate,
audited act rather than a mid-year numbering change nobody decided.
Payment rows bound to the other bokföringsmetod are dimmed, not hidden.

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

* test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A

voucher-series-defaults.pg.test.ts codified the pre-#2184 column default
(every source type on A). Migration 20260906210500 replaces that default
with the standard set, so the "freshly inserted row" case now asserts the
representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP.
The explicit-override case keeps proving a company's own layout replaces
the default wholesale. No other pg or tool test asserted on the old map.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:18:22 +02:00
Jakob Wennberg 4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

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

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00
Jakob Wennberg 0069a3f59a feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran

Skatteverket decides per begäran but pays everything it decided that day in
one transfer, so the bank row often equals no single open begäran and the 1:1
matcher from #2271 stayed silent; the settle service then refused the amount
and the dialog told the user to split the transaction by hand.

The candidate is now the exact covering set of 1..4 open begäran whose
expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts,
over the existing findExactCoveringSet, ambiguity-refusing). It is computed
at read time from the open pool (inbox page, worklist, ingest), no hint
column. Confirming books ONE voucher (debit 19xx, one 1513 credit per
begäran) through the same writer, marks every begäran paid and links the row
once; a bundle is always booked at exactly the decided sums.

- match-rot-rut-payout accepts request_ids (1..10) beside request_id
- settleRotRutPayoutRequestSet shares the single path's tail
- createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged)
- inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set
- new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set

Closes #2239

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

* fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle

The bundle path booked every leg as fully paid and mirrored requested_amount
onto every begäran's items, while the single path completes a begäran only
when the leg covers requested_total and otherwise leaves it partially_paid
for manual handling. A begäran Skatteverket decided at less than requested
is a legitimate bundle member (its leg is the beslut, the exact-sum rule is
unchanged), so the set path now computes fullyPaid per leg exactly like the
single path, passes it to the shared attachSettlementVoucher, and mirrors
only the fully paid legs; sibling hints are still cleared for every settled
begäran, which carries a voucher and is no longer matchable either way.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:13:53 +02:00
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 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 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