Commit Graph

614 Commits

Author SHA1 Message Date
Mattsson b069d9a9fe fix(import): keep mapping confirmation visible (#1684)
* fix(import): keep mapping confirmation visible

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): keep source names masked

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
2026-08-18 23:20:23 +02:00
Mattsson 3ec76d39db fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
2026-08-18 23:00:44 +02:00
Jakob Wennberg 9d59e509ab fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX (#1699)
* fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX

Founder review of #1690 (2026-08-18), two decisions.

Declutter (design B): the separate Skattereduktion card on the invoice
detail page duplicated the totals block. It is gone; what it carried
beyond the amounts now lives in Detaljer as plain rows, only for invoices
with a claim: Personnummer (masked, or "Saknas"), Fastighet (ROT only:
fastighetsbeteckning or BRF, with lagenhetsnummer inline), and
Skattereduktion with the begaran lifecycle ("Ej begard" + inline "Skapa
begaran" link when paid and unclaimed; otherwise the rot_rut_status_*
label, date and decided amount), styled like the neighbouring Bokforing
row. Totals block unchanged. Per-line subtext shortened to
"<RUT|ROT> · <arbetstyp> · <n> tim" (desktop + mobile).

Personnummer mask: invoice surfaces now show YYYYMMDD-XXXX (birth date
visible, last four hidden), the payroll convention (maskPersonnummer),
instead of XXXXXXXX-<last4>. Computed on read from the stored
AES-GCM ciphertext by lib/invoices/deduction-personnummer.ts: no schema
change, nothing stored, never throws (bad ciphertext logs and renders no
personnummer). InvoicePDF derives it itself when given the stored row so
no render call site can drop it; the preview route passes an
already-masked value (it only has the typed plaintext or the kundkort
fallback). The v1 pdf/send routes fetch the ciphertext for the render
only; INVOICE_FULL_COLUMNS / INVOICE_PDF_COLUMNS stay as pinned. The
detail page and the editor's kept-hint read the mask from the new
GET /api/invoices/[id]/rot-rut (withRouteContext, company members),
which never returns the last four alongside the mask. v1 REST and MCP
keep deduction_personnummer_last4 for compatibility.

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

* fix(invoices): stack the ROT/RUT claim state and action in Detaljer

At the sidebar card width "Ej begard" and "Skapa begaran" wrapped mid-word
side by side (seen in the sandbox on a paid invoice). Same shape as the
Bokforing row now: state on top, the action under it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 21:26:52 +02:00
Jakob Wennberg 83932f2e07 fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it (#1692)
* fix(salary): show the AGI kvittensnummer from agi_declarations regardless of who fetched it

When the kvittens cron (or the post-connect refresh) picks up a signed AGI it
deletes the period-scoped agi_submission_{period} cache on purpose, and the
salary run then rendered "Skickad till Skatteverket <date>" with no
kvittensnummer, signatory or signing time even though all three were stored
on agi_declarations. Since the cron runs every 15 minutes while the panel
polls only three times after the signing link is created, that was the
normal outcome for anyone who signs at an unhurried pace (#1597).

GET /agi/status now serves the receipt from agi_declarations
(kvittensnummer, response_data.signeradAv/signeradTid, submitted_at,
submittedAtEstimated) whenever the cache is absent; the cache still wins
when present because it is the only place the in-flight states live. The
declaration-sourced record deliberately carries no salaryRunId (the period
row is repointed at a correction run on regeneration), so ownership is
resolved from signeradTid/submittedAt against the run's agi_submitted_at
stamp and from updatedAt = submitted_at. AGIPanel labels the timestamp as
approximate when it is our reconciliation-time fallback rather than
Skatteverket's signeradTid. The MCP gnubok_agi_status tool uses the same
read.

Closes #1597

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

* ci: retry stalled Vercel preview build

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:32:23 +02:00
Mattsson d0640e0968 fix(settings): clarify bankgiro source on Foretag tab, offer IBAN prefill from bank connection (#1695)
* fix(settings): stop registry bank data masquerading as a setting, offer IBAN from bank connection

User report: the Foretag tab shows a bankgiro from the Bolagsverket snapshot,
which reads as a configured setting while the field payment files and
invoices actually use (Fakturering) was empty.

- Note on the Foretag Bankuppgifter row: data is from Bolagsverket; the
  editable fields live under Installningar -> Fakturering.
- One-click IBAN prefill on the SEK payment account, sourced from the
  connected bank accounts (cash_accounts.iban). Deterministic: only offered
  when every connected account agrees on a single IBAN.
- Delete dead BankDetailsForm.tsx (unmounted since the settings
  restructure); its bank fields are edited via InvoicePaymentAccountsSettings.

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

* fix(settings): only suggest IBAN from enabled, still-connected SEK accounts

Skeptic refutation on the initial PR state: cash_accounts keeps rows after
disconnect (bank_connection_id nulled) and the connect picker mirrors
deselected accounts with enabled=false, so an unfiltered read could offer a
closed or third-party IBAN as the invoice payee / pain.001 sender. Filter on
enabled=true, currency=SEK and a non-null bank_connection_id, matching the
enable-banking session-sharing invariant.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:18:51 +02:00
Jakob Wennberg ffa18019f4 fix(invoices): carry ROT/RUT deduction into the editor PDF preview (#1687)
The preview route built previewInvoice without any deduction fields and
its item mapping dropped deduction_type, so the editor's PDF preview of a
ROT/RUT invoice showed no avdrag row, no deduction info box, and "Att
betala" at the full undeduced total, unlike the invoice that is then
created and sent.

The preview now mirrors build-invoice-write.ts: per-line deduction_amount
via computeDeduction (base inkl. moms at the rendered rate, invoice
document type only), invoice-level deduction_total via
computeInvoiceDeductionTotal, and the per-line work_type / labor_hours /
housing fields the PDF's info box reads. The masked personnummer is
resolved like the write path (typed value, else an individual customer's
kundkort personnummer). The editor posts deduction_personnummer and
deduction_housing_designation to the preview only when a line claims a
deduction.

Closes #1686

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:32:32 +02:00
Jakob Wennberg 3ea03c0fe1 fix(import): stop the generic CSV mapper picking a time column as description (#1689)
A Lunar 2026 export (Date, Time, Title, Amount, Balance, Transaction ID)
that reached the manual "Annan CSV" mapping was seeded with Time as the
description: no description keyword matched Title, and the positional
fallback took the first non-numeric, non-date column, which is the clock
time sitting between Date and Title.

- suggestColumnMapping: add title / titel to the description keywords;
  exclude clock-time columns from every description pass, by header
  label (Time, Tid, Tidpunkt, Klockslag, Transaktionstid, ...) and by
  HH:MM / HH:MM:SS values, so header-less files are covered too. Last
  resort still seeds something the user can correct.
- Lunar detector: sniff the delimiter (comma, semicolon, tab) instead of
  refusing any file containing a semicolon, so a re-saved or localized
  copy of the same English header set is parsed by the dedicated parser
  and never reaches the mapping flow. Header cells are matched exactly
  (date, title|text, amount, balance), the same resolution parse() uses,
  which also stops substring hits like Update/Context from claiming a
  file.
- Mapping UI header-row detection: add title / balance to the keyword
  list for English exports.

Regression tests: Lunar-style header through the generic path maps Title,
a header-less Time column is skipped by value, Datum;Tid;Titel maps
Titel, semicolon- and tab-delimited 2026 Lunar files detect and parse,
Swedish and non-Lunar English headers are not claimed. All 7 fail without
the fix.

Closes #1671

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:22:14 +02:00
Jakob Wennberg 1000f18169 fix(invoices): real empty states in the editor pickers (#1678)
Zero customers rendered the customer Select as a bare few-pixel sliver;
it now shows 'Inga kunder än'. The supplier menu showed an orphan
separator above its create action when no suppliers exist; it now shows
'Inga leverantörer än' and drops the separator. The row-entry suggestion
hint loses its top border when no article list renders above it.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 11:09:43 +02:00
Mattsson cfdddb2d7e feat(mcp): customer_number on create_customer + Beta tags on webshop surfaces (#1677)
* feat(mcp): accept customer_number on gnubok_create_customer

Parity with gnubok_update_customer: a customer number no longer needs a
create-then-update two-step with two approvals. The staged params carry
the trimmed number, commitCreateCustomer inserts it, and the payload-size
ceiling is bumped 59.7K to 59.75K with a documented entry (the property
has no description; name + maxLength are the whole contract).

Requested by a user on Discord 2026-08-16.

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

* feat(ui): mark webshop integrations and orders tab as Beta

WooCommerce and Shopify rows on the import page get a quiet Beta chip
next to the title, and the webshop /orders sidebar item sets the
existing betaBadge flag. Chip recipe matches the nav beta badge so
Beta reads identically everywhere.

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

* fix(mcp): enforce customer_number invariants and show it on the approval card

Consolidated resolution pass for PR #1677:
- skeptic (correctness): maxLength 32 was advertisement-only on the create
  path; now enforced with a runtime guard in gnubok_create_customer execute
  (clean errors for non-string and >32) and a 400 guard in
  commitCreateCustomer, matching the web/v1 routes and commitUpdateCustomer.
- skeptic (correctness): CustomerPreview never rendered the staged
  customer_number, leaving the approver blind to the new field; added a
  conditional Kundnr row.
- CodeRabbit: reset the event bus in create-customer.test.ts beforeEach.
- Tests cover both new guards at the tool and executor layers.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:46:34 +02:00
Jakob Wennberg 387e1fb7f1 fix(import): let a skattekontoutdrag that does not sum through a confirm gate (#1675)
* fix(import): let a skattekontoutdrag that does not sum through a confirm gate

The skattekonto file parser refused any statement where ingående saldo plus
händelser did not equal utgående saldo with a bare 400 and no figures. A
real export hit it on 2026-08-18 and the user had no way forward, and the
logs carried nothing to diagnose it with. Nothing is booked at import and
the dedup contract makes a later complete re-import safe, so refusing the
file only blocked the rows that WERE readable.

- Parser: report events_sum / sum_difference / unreadable_amount_rows
  instead of just a boolean; reduce several marker pairs to the earliest
  opening and latest closing (per-year sections, newest-first files); read
  a marker saldo from a trailing running-saldo column when the belopp cell
  is empty; accept U+2212 and dash lookalikes as minus and a leading plus.
- Route: no longer 400s on sum_valid=false; logs the figures (amounts and
  counts, never row text) so the next report is diagnosable. Zero readable
  rows still refuses. SKATTEKONTO_FILE_SUM_MISMATCH removed (unused).
- Preview: an "Utdraget summerar inte" card with ingående, händelser,
  ingående+händelser, utgående and differens plus a confirm checkbox that
  gates the import button, mirroring the orgnr-mismatch gate. A one-line
  note explains that nothing is booked at import and that events already
  carrying a 1630 verifikat are offered as a link, not a second booking.

Verified end to end in the sandbox: gate renders, import proceeds after
confirmation, rows land on /skattekonto with Matcha/Bokför.

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

* fix(import): round the derived händelser total and fall back to the date cell for an invalid marker date

Review nits on #1675.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:33:51 +02:00
Jakob Wennberg 2b5b813b7a feat(invoices): rebuild the invoice editor as the snabbflöde single column (#1654)
* refactor(invoices): extract editor payload builders with parity tests

Extract the three near-identical inline payload builders in InvoiceEditor.tsx
(handleConfirm, saveDraftData, saveEdit) and the self-billed body mapper into
pure functions in lib/invoices/editor-payload.ts. Zero behavioral change: the
new lib module carries a 300-case parity suite asserting JSON byte equality
against verbatim copies of the legacy inline recipes across the full
mode x deduction x dimensions x ore-rounding matrix. This is the
byte-compatibility ratchet under the upcoming editor re-layout: the repo
renders no components in tests, so the wire bodies are what CI can pin.

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

* feat(invoices): rebuild the invoice editor as the snabbflöde single column

Reshape InvoiceEditor to the approved prototype: one 640px column with
uppercase section labels and honest state marks (RequiredMark asterisks,
sage check on a picked customer, muted row counts), a dense in-table rows
surface with a unified last-row entry (autocomplete over the artikelregister,
italic ghost cells, Enter commits free text and lands in the price cell,
ArrowDown+Enter commits an article through the same applyArticle side
effects), hover-revealed 24px row controls with 40px coarse-pointer targets
and per-row aria-labels, a Förval chip line whose collapsed settings
re-surface as chips whenever a value deviates from its default (critical in
edit/copy so PATCH never round-trips invisible values), a single ochre
next-step line (aria-live polite) that doubles as the invalid-submit focus
router, and a sticky bottom action bar with the live total: position sticky
in both hosts, never fixed, since DialogContent's transform re-anchors fixed
children in bare mode.

Behavioral deltas, all pre-decided: the primary action is never disabled
pre-click for writable users (viewers keep the lock+tooltip treatment);
client-side validation failures route focus instead of toasting; genuine
field errors stay terracotta and field-adjacent while the two ochre
disclosures (taxed-where-performed, labor-only) demote to muted text;
committed free-text rows expose a quiet Spara-som-artikel link; the review
dialog lists the applied förval (currency, öre rounding, payment-link
state); a freshly committed row gets a brief background settle that
collapses under prefers-reduced-motion. ArticleCombobox gains the missing
combobox ARIA (listbox/option roles, aria-controls, aria-activedescendant
only after explicit arrowing). New pure module invoice-editor-flow.ts pins
the next-step priority order, the Förval chip derivation and the suggestion
filter with unit tests. All payload builders, submit targets and the VAT
baseline refs are untouched.

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

* fix(invoices): editor review nits: orphaned keys, housing gate, listbox ARIA

Three review findings on the snabbflode editor:

- Delete 13 orphaned invoice_editor keys from both message files
  (subtitle_*, add_row, remove_row, remove_row_aria, details_card_title,
  save_as_draft_short, validation_toast_*, delivery_date_placeholder);
  each verified unused on the branch, sv/en parity kept.
- Gate the housing next-step on a claimed deduction amount so it matches
  the ROT/RUT claim card's mount condition: a ROT-flagged line with a
  zero amount mounts no card, and the ochre link would try to focus an
  unmounted field. Extracted as deriveRequiresHousing in the flow module
  with a test proven to fail on the old gate.
- Move the entry-row popover hint out of the role=listbox element
  (listbox children must be options) into a sibling inside the absolute
  wrapper, referenced via aria-describedby on the combobox input.

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

* feat(invoices): drop the in-editor faktura/sjalvfaktura tabs

The Ny faktura split button already chooses the mode (?self=1); a second
switcher inside the editor was double steering. The mode is now fixed for
the editor's lifetime and the heading (Registrera sjalvfaktura) carries
the distinction. Orphaned tab keys removed from both message files.

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

* fix(invoices): wrap sticky-bar actions so they fit small viewports

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

* fix(invoices): stop dialog grid item overflowing small viewports

min-w-0 on the editor root: DialogContent is display:grid, so the row
grid's min-w otherwise forces the column past narrow screens.

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

* fix(invoices): lift assistant FAB above the standalone editor's action bar

The rebuilt editor introduces the first page-level sticky bottom bar; the
assistant FAB (fixed, z-30) covered its Spara/Granska buttons on the
/invoices/[id]/edit page. The editor now sets body[data-page-bottom-bar]
in non-bare mode and AgentTrigger lifts to bottom-20 when it is present.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:56:35 +02:00
Jakob Wennberg 93e99012d7 feat(supplier-invoices): dokument-forst editor rebuild (prototype shell + 4 flow optimizations) (#1653)
* refactor(supplier-invoices): extract payload builder and form hooks, pin wire contract with parity tests

Zero visual/behavioral change. Pulls the pure payload builder
(buildSupplierInvoicePayload + inferVatTreatment + vatRateFromAi) out of
NewSupplierInvoiceForm into lib/supplier-invoices/form-payload.ts and pins
it with a mode/feature-matrix parity test suite (document_id vs inbox,
privately paid due-date default, reverse charge rate forcing, accrual
attach/drop, dimensions bags, apply_slp validity, FX parsing, empty-string
stripping, ore_rounding passthrough).

Also extracts, verbatim: the VatRateCell/RcRateSelect cells, the reference
data loading hook (suppliers/accounts/settings/periods), the inbox AI
prefill hook (exposing applyInboxItem for reuse), and the submit
orchestration hook (endpoint chooser, three submit paths, duplicate-number
conflict recovery, inbox field sync-back).

Deliberately NOT moved: the effect-ordering couplings
(pendingAccountFillRef/accountFillTick supplier-defaults dance, the
icke-momsregistrerad gross-up re-run keyed on hasPrefilled, the RC
accrual-clearing effect, per-currency FX touched flags) stay in the
component untouched; their ordering semantics are load-bearing.

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

* feat(supplier-invoices): dokument-forst editor rebuild with prototype shell and four flow optimizations

Rebuilds NewSupplierInvoiceForm to the approved Leverantorsflodet prototype:
single 640px column, flat sections (Underlag first, then Leverantor,
Fakturauppgifter, Kontering, Forval, Summering), honest state marks
(RequiredMark, sage checks for binary facts, muted row counts), a single
ochre next-step line (aria-live polite) whose link focuses the missing
field, and a sticky bottom action bar with the live total that binds to the
dialog scroll container in bare mode and the page panel scroll standalone.

Dokument-forst (1): the standalone upload now tries the invoice-inbox
pipeline over HTTP first (POST upload, poll items/:id past 'processing'),
then runs the same applyInboxItem prefill path as an inbox arrival
(settle tint on filled fields, reset(getValues()) dirty baseline, submit
through the convert endpoint so the document links and the item is stamped).
Extension off or extraction failed degrades to the plain /api/documents
attachment; manual entry is never blocked.

Total cross-check (2): optional "Totalt enligt fakturan" field in
Summering, client-only compare against the displayed payable (sage match
line, terracotta diff line), prefilled from extraction totals.

Duplicate advisory (3): new index-only GET /api/supplier-invoices/exists
(withRouteContext + validateQuery, mirrors the partial unique index's
credited/reversed exclusion, full route tests), debounce-called on
fakturanummer change; terracotta field-adjacent line with a link to the
existing invoice. The structured 409 conflict dialog stays the backstop.

Terms-based due date (4): muted caption "Fran leverantorens villkor
(N dagar)" when auto-set, re-derives on invoice-date and supplier change,
stops the moment the user or the AI supplies a date; terms 0 leaves the
field empty with "Star pa fakturan".

OCR hint (5): "Anvands i betalningsfilen." under the payment reference when
the chosen supplier has bankgiro or plusgiro.

Table model: rows start empty; the ghost tfoot entry row (never part of
form state) commits an account via the existing AccountCombobox (opens on
focus, Enter commits) and moves focus to the new row's amount cell; the
supplier default/history fill plants the first row when the table is empty.
Row controls are hover-revealed via HOVER_REVEAL_CLASS at a 24px hit area
with per-row aria-labels carrying the description. The primary button is
never disabled pre-click for writable users (in-flight only); every
submit-time hard block stays in onSubmit; viewers keep the lock treatment.

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

* fix(supplier-invoices): re-run gross-up per apply, guard deferred prefill, honest un-plant

- Gross-up/zero-rate pass for icke momsregistrerade re-runs per applied
  extraction (applyCount bumps in applyInboxItem) instead of keying on the
  one-shot hasPrefilled flag: a remove + re-upload could previously push AI
  25 % rates to the convert endpoint with the moms columns hidden.
- Deferred extraction on the standalone upload path no longer overwrites what
  the user typed mid-poll: the result auto-applies only while the form is
  pristine (live isDirty ref), otherwise it is buffered behind a quiet
  "Tolkning klar" click-to-apply line. Inbox arrivals are unchanged.
- Supplier-switch un-plant keeps rows the user edited in ANY field, not just
  amount (plant-time snapshot compare in lib/supplier-invoices/planted-rows.ts,
  since dirtyFields is unreliable for appended array rows), clearing only the
  stale account; untouched plant-created rows are still removed and rows that
  existed before the fill are never removed.
- default_expense_account plants now register in plantedRef too, so a supplier
  switch un-plants them under the same rules as history plants.
- applyInboxItem reads suppliers through a ref: the 90 s poll no longer
  resolves matched suppliers against a stale empty list.
- The duplicate advisory bumps its seq in the clear branch, so an in-flight
  exists response cannot resurrect a warning under a cleared field.
- Drop 7 orphaned supplier_invoice_editor keys from both message files.

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

* fix(supplier-invoices): retry the entry-row focus hand-off on the next frame

A single requestAnimationFrame after appending the row can fire before the
new amount input's ref is mounted, silently dropping the focus hand-off
(observed in headless verification). One retry frame makes the signature
interaction reliable.

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

* fix(supplier-invoices): deterministic entry-row focus hand-off via effect

The rAF retry still lost to the dialog focus scope re-parking focus when
the entry input remounts mid-commit. An effect keyed on the pending row
index runs after the new row's input has mounted and wins deterministically.

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

* fix(supplier-invoices): comma-tolerant amount cell and surviving focus routing

The focus trace exposed two real issues behind a probe mystery: the amount
cell was type=number (ArrowDown decrements money by 0.01, Enter fires the
form's implicit submit mid-edit, and Swedish comma decimals are rejected
outright), and the supplier menu's close-autofocus yanked focus back to
the trigger, undoing the routed hand-off to the invoice-number field.

AmountCell mirrors VatRateCell's draft pattern: text input with decimal
inputMode, digits-and-one-separator whitelist, Enter commits via blur.
The supplier DropdownMenuContent prevents default close autofocus.

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

* fix(supplier-invoices): show comma decimals in the amount cell display

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

* fix(supplier-invoices): stop dialog grid item overflowing small viewports

min-w-0 on the form root (DialogContent is display:grid, so the kontering
table's min-w otherwise forces the column past narrow screens) and wrap
the sticky-bar action cluster.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:50:48 +02:00
Jakob Wennberg 798a76ed7a fix(invoices): accept USD/GBP payment accounts without an IBAN (#1649)
Payment accounts per currency required an IBAN for every non-SEK
currency. USD (ABA routing number) and GBP (sort code) accounts have no
IBAN, so a Wise US or UK receiving account could only be saved by
pasting an IBAN from another currency, which then printed on the invoice
and misrouted the payment.

- InvoicePaymentAccount gains bank_code (routing number / sort code) and
  foreign_account_number; JSONB column, no migration.
- Rule, shared by the Zod schema, the client validation and
  hasUsableInvoicePaymentAccount: a foreign account is usable with an
  IBAN, or, only for NON_IBAN_CURRENCIES (USD, GBP), with bank_code +
  foreign_account_number + BIC. EUR/NOK/DKK still require IBAN.
- Settings: the two fields appear only for USD/GBP with the identifier
  named per currency (Routing number (ABA) / Sort code), a hint that IBAN
  may be left empty, and IBAN no longer marked required there.
- Invoice PDF renders the routing row with the same per-currency label
  plus the foreign account number, in both sv and en.

Reported via gnubok_feedback 2026-08-03.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:23:36 +02:00
Jakob Wennberg 76b8d5c100 fix(pending): show the staged kontering and bank currency on the bulk_book_transactions approval card (#1648)
The /pending card (and the chat ApprovalCard, same OperationPreview
dispatch) for bulk_book_transactions rendered only aggregates: tx_count,
tx_date, tx_sum, direction, mode. The staged journal lines sat unused in
params.new_entry.lines even though the executor's RPC posts them
verbatim, so the human approving an AI-staged samlingsverifikat could
not see which accounts were debited or credited: "-720, 2 tx, expense"
is compatible with both a correct booking and a wrong one.

- Staging now writes preview_data.lines (account_number, chart or BAS
  account_name, debit/credit, line text) and entry_description, using
  the same account-name lookup as gnubok_create_voucher, plus the bank
  rows' currency. Nothing beyond what create_voucher already exposes;
  still no per-tx descriptions or counterparty identifiers.
- New BulkBookPreview renders those lines with the create_voucher table
  and totals, and shows the bank sum in the rows' own currency.
- CategorizePreview labels the source bank amount with its currency when
  it is not SEK, next to the (always SEK) journal lines: a 2 500 USD
  receipt booked as 24 292,50 kr read as a wrong SEK figure to an
  approver who saw only one of the two numbers.

Reported via gnubok_feedback 2026-07-13 and 2026-07-14 ("the human-in-
the-loop control is the safety mechanism, and it is currently blind").

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:21:53 +02:00
Jakob Wennberg e030393fe6 fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness

Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md).

Payment side (fakturamodellen: the customer pays total minus avdraget, the
rest is a 1513 receivable on Skatteverket):
- createInvoicePaymentJournalEntry without an explicit paymentAmount used to
  book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP
  mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the
  outstanding amount, so on a ROT/RUT invoice 1510 went negative by the
  deduction and 1930 was overstated; same defect for any previously part-paid
  invoice. It now books the outstanding amount (remaining_amount, else total
  minus paid_amount); a fully outstanding invoice keeps the total_sek path.
- proposePaymentLines had no deduction awareness: the payment dialog
  pre-filled D1930 total / K1510 total, which the settlement plan rejected as
  an overpayment, so a ROT/RUT invoice could not be marked paid from the UI.
  Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the
  customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices
  without a booking rate refuse (1513 is a kronor receivable). Dialog passes
  deduction_total.
- Reminders and dröjsmålsränta were computed on invoice.total: a privatperson
  was dunned for the Skatteverket share and charged interest on it. New
  reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus
  avdrag) drives the processor's interest base and all three templates.

Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar):
- work_type and labor_hours were optional at creation but hard blockers at
  begäran-file time, when the invoice is numbered, booked and paid and cannot
  be edited. validateDeductionLines() now requires a same-kind arbetstyp and
  hours > 0 (schablontjänster exempt) on every deduction line; wired into
  validateInvoice, CreateInvoiceItemSchema (field-level issues) and the
  editor schema with inline errors under the ROT/RUT strip. Fixed the
  labor_hours register (valueAsNumber overrode setValueAs: an emptied field
  became NaN and failed validation with no visible error). The Underlag card
  now shows whenever any row is flagged, matching the payload/server predicate.

Yearly ceilings:
- COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped
  at 50 000 inside it). deductionCapWarnings() carries the per-kind and the
  combined check plus optional prior-year totals; validateInvoice forwards
  them; the editor uses the same helper and fetches what the customer has
  already been granted in the invoice year (per customer, warning only).

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

* fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment

Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox
seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has
~330 such open invoices. Booking 0 would have failed the engine's positive-
amount rule, so the outstanding helper derives total - paid - deduction when
the stored value is not positive. Test.

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

* fix(rot-rut): review follow-ups on #1652

- ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema /
  UpdateInvoiceSchema share one refine) so it only applies to real invoices
  and skips text rows; the editor gates its mirror on the document type via
  a ref. Tests moved accordingly (CodeRabbit).
- Prior-year deduction lookup follows the PAYMENT year (paid_at, else
  invoice_date for open invoices), paginates via fetchAllRows, and clears the
  total on a failed request instead of leaving a stale one.
- rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the
  validator and the generator cannot drift.

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

* fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling)

The runtime-built .or() filter counted as an unresolvable query expression
for the no-phantom-columns guard. A customer has few deduction invoices, so
fetch them all and select the payment year in code.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:49:27 +02:00
Jakob Wennberg 79240cb2ed fix(articles): article ROT/RUT prefill was dead for every dashboard-created article (#1651)
* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article

Follow-up to #1634. The user re-tested and picking a RUT article still left
the line on "Ingen": the article form has always stored the bare kind
('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes
(BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the
bare kind, so the fix in #1634 never fired for a real user, and worse, since
the helper returned null for those values, picking such an article CLEARED a
deduction the user had set manually on the row.

- rot-rut-rules: parseArticleHouseworkType() understands both vocabularies
  (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus
  normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel().
- InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and
  keep a same-kind arbetstyp already chosen on the row; "Spara som artikel"
  round-trips the code or, lacking one, the kind.
- ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper
  in ROT/RUT groups (its own hint always promised "förifyller arbetstyp");
  legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an
  edit never silently drops the flag. Article detail renders "RUT · Städning"
  instead of the raw code.
- API + MCP commit schemas normalize housework_type (case-insensitive code or
  ROT/RUT, '' clears) and reject anything else; the CSV article import
  normalizes the column the same way. Prod holds 178 articles with '0'/'1'
  from a boolean "Rot" column that the keyword detector mapped straight
  through; those now read as no flag everywhere and can no longer be created.

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

* fix(articles): review follow-ups on #1651

- InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an
  arbetstyp from the other list, and Spara som artikel only round-trips a
  work type that belongs to the row's kind (CodeRabbit).
- MCP update_article: null / '' / whitespace now clear housework_type
  (commit drops only undefined keys, so the old undefined mapping made the
  flag un-clearable); create keeps treating them as unset. Tests.
- Article CSV import warns when a non-empty ROT/RUT value is dropped as
  not-an-arbetstyp instead of dropping it silently. Test.
- Hint wording: arbetstyp is pre-filled only when the article carries one.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 19:47:29 +02:00
bjornbergenheim 3841ab9f54 feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)
gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat
links as a single pending operation, addressed by voucher_series /
voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk
receipt-migration jobs where N separate tools mean N separate approvals.

Staging resolves every row server-side and returns a per-row hit or miss,
so a systematic offset such as a wrong fiscal_year is visible before
anything is approved rather than after N approvals. Only resolved rows
enter the staged operation.

The WORM precondition and the document lookup are shared with the
single-document executor through precheckDocumentLink: a bulk call must
enforce exactly the invariants N single calls would, and a second copy of
a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the
first is hardened.

A batch that links nothing returns 409 instead of a committed no-op.
Partial skips stay committed, but an approval-gated operation on
räkenskapsinformation must not leave an audit record asserting a run that
changed nothing.

The tool is search-only: a one-off migration tool does not belong in the
default catalog every session pays for in context, and keeping it there
pushed the tools/list projection past the 58.5K token ceiling that
payload-size.bench.test.ts guards.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:52:58 +02:00
Jakob Wennberg 93541d7186 fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges (#1633)
* fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges

Follow-up batch to #1629: the six documented deferred items from
dev_docs/loading_states_analysis.md, in the same vocabulary (first-load-only
takeovers, background reconcile behind mounted content, row/button-level
pending, sequence guards).

- Invoice detail pages: kundfaktura and leverantorsfaktura detail no longer
  blank the whole page for one-field changes. fetchInvoice shows the blocking
  spinner/skeleton only before the first paint (or when the pager steps to a
  different invoice); Bokfor / status / finalize / payment / send / Attestera /
  Markera betald / kreditera refetch behind the mounted page, the acting
  button shows a spinner-in-button, and the handlers await the refetch so
  pending covers until the content reflects the new state. The supplier
  detail's single isProcessing boolean became processingAction so the spinner
  lands on the clicked button only. (The leverantorsfakturor LIST
  try/catch/res.ok item was already fixed by #1629.)

- useDestructiveConfirm: confirm(opts, action?) can now carry the destructive
  operation, so the dialog's existing isLoading spinner actually shows while
  it runs, dismissal is blocked meanwhile, and confirm resolves false if the
  action throws. Adopted at the /transactions row delete and the supplier-
  invoice detail delete (which previously permitted duplicate DELETEs with
  zero feedback).

- Batch parallelization: new lib/concurrency.ts mapWithConcurrency (bounded
  worker pool, order-preserving, tested). /transactions batch categorize /
  ignore / delete run per-row requests 5 at a time instead of strictly
  sequentially; the bulkbar counter ticks per completed row.

- Toast-spam reduction: batch categorize rows run silent (exit animation,
  count decrement and state patch stay; no per-row Bokford or generic failure
  toast) and ONE aggregate toast reports "N bokforda[, M misslyckades]" with
  a single Angra alla action that pools the same /uncategorize endpoint over
  every booked row (per-row undo is feasible today, so the aggregate is too).
  Interactive escalations (SI/CI match suggestions, duplicate warning,
  activate-account) deliberately keep their dialogs.

- Underlag row-click flash: InvoiceInboxWorkspace handleSelect seeds the
  detail pane synchronously from the clicked list row and starts the document
  load in parallel with the detail GET (which hydrates on arrival), so a row
  click never flashes the onboarding/empty state, and a stale-response guard
  keeps a slow fetch from overwriting a newer selection.

- #1629 round-2 edges: /pending holds the loading state when a fetch for a
  not-yet-loaded tab FAILS (never renders the previous tab's rows under the
  new tab's header, and never fakes an empty state); /transactions clears
  transactions/skvRows (+ count/paging) and bumps both fetch sequences on
  company switch, and loadSkvRows got the same sequence-guard pattern as
  fetchTransactions.

Gates: full vitest suite green (14772 passed), tsc byte-identical to the
origin/main baseline (stash-diffed), eslint 0 errors on touched files
(warnings identical to baseline), check:guards green, package-lock untouched.

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

* fix(ui): harden action feedback against stale responses and failures

Address the seven CodeRabbit findings on #1633:

- invoices/[id] + supplier-invoices/[id]: latest-request guard in
  fetchInvoice (sequence token) so a mutation refresh overlapping pager
  navigation can never commit invoice A's state under invoice B's URL;
  the deferred related-document writes are guarded too
- supplier-invoices/[id]: try/catch/finally in approve/book/mark-paid/
  credit/uncredit so a rejected fetch()/json() clears processingAction
  instead of leaving every invoice action disabled until reload
- transactions: extend the skattekonto sequence guard to the
  connection-status write so a status response started under the
  previous company cannot flip the reconnect banner for the new one
- transactions: runCategorize resolves { ok, journalEntryId } so the
  batch aggregate counts a 200-with-null-journal-entry booking (flag
  flip) as success instead of narrating it as misslyckades; Angra alla
  only targets rows with an actual verifikat, since the storno endpoint
  rejects rows without one
- transactions: shared undoneIdsRef lets "Angra alla" cancel a pending
  finishBooking state patch; a fresh booking clears its row's entry so
  re-booked rows still get their delayed patch
- InvoiceInboxWorkspace: monotonic request tokens for the detail and
  document reads so a same-item reload cannot resolve out of order and
  paint a stale snapshot or document URL
- messages: ICU plural for the success part of both partial batch
  descriptions in sv and en (1 bokford, not 1 bokforda)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:30:25 +02:00
Jakob Wennberg 4921d1da5e feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)
* feat(import): import skattekontoutdrag files into the skattekonto pipeline

Users can now upload the kontohändelse export from Skatteverket's
skattekonto e-service (current CSV layout, verified against a real
2026-08 export, plus legacy .skv files) instead of needing the paid API
connection. Parsed rows land in skattekonto_transactions as booked
file_import rows and inherit the existing 1630 rules engine, bulk
booking, match-to-verifikat and both UIs unchanged.

- Core parser lib/import/skattekonto-file/ with strict detection
  (orgnr header + saldo markers, or two distinct SKV vocabulary terms
  plus row shape), sum-integrity check (opening + rows must equal
  closing) and a wrong-company guard against company_settings.
- computeDedupKey moves to core (lib/skatteverket/skattekonto-dedup);
  the extension re-imports it. File rows hash-key; content-signature
  partitioning skips rows already booked (either key form) and promotes
  matching upcoming rows in place.
- syncSkattekonto gains a takeover step: an id-keyed API row adopts a
  matching hash-keyed imported row in place, so journal links survive
  connecting the API after a file import. Upcoming rows can no longer
  clobber a booked row on hash collision.
- New skattekonto_file_imports table (company-scoped file-hash dedup)
  plus source/file_import_id provenance columns on
  skattekonto_transactions.
- /import gains a Skattekontoutdrag wizard (upload/preview/result,
  deep link ?mode=skattekonto); the bank-file flow detects skattekonto
  files and redirects instead of importing them as bank rows.
- /skattekonto renders imported rows for unconnected companies (attn
  line + import CTA) instead of discarding them behind the StartCard.
- Free for everyone: the local-data booking/match routes were already
  ungated; only API sync/saldo stay capability-gated.

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

* fix(skattekonto): align the EF F-skatt rule with the 2012 -> 2013 decision

20260810120000 established that 2012 is not standard BAS and moved the
booking templates to 2013 (owner taxes in an enskild firma are an eget
uttag), but the skattekonto_rules seed still booked EF preliminarskatt
against 2012. The file importer makes this rule fire for every EF
F-skatt row, so bring it onto 2013 too.

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

* fix(import): apply review findings on the skattekonto file import

- Fix the takeover candidate comparator: the single-argument sort was an
  inconsistent relation and could adopt a stale upcoming row ahead of the
  booked file row in a 3+ candidate queue (regression test added), and
  page the candidate scan with fetchAllRows so a multi-year window is not
  silently capped at 1000 rows.
- Fail parsing when a statement HAS saldo markers but not both readable
  balances: a file cut off before "Utgående saldo" previously skipped the
  sum check entirely. sum_valid stays null only for marker-less legacy
  files.
- Count a promotion only when the UPDATE matched a row, so a concurrent
  sync cannot inflate promoted_count; log a failed finalize of the import
  record instead of discarding the error.
- Migration (unshipped, edited in place): user_id is nullable with
  ON DELETE SET NULL so import records and their file-hash dedup survive
  user deletion, and the INSERT policy binds user_id to auth.uid() so a
  member cannot attribute an import to a colleague. pg tests cover both.
- Make the upload drop zone keyboard-reachable (role, tabIndex, Enter/
  Space) and give the six count-bearing strings ICU plural forms in both
  locales.

Skipped with reasons on the PR: binding execute rows to file bytes and
re-checking orgnr in execute (same client-trust model as the shipped
bank-file execute; Zod + RLS scope writes to the caller's own company),
a 404 test (the route has no not-found path), event-bus clearing in the
route test (the route touches no events), and FK NOT VALID (new column
referencing a brand-new empty table).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:18:32 +02:00
Mattsson dfb34a01d9 feat(invoices,year-end): four byrå-feedback fixes (validation feedback, moms gate, klarmarkera, article search) (#1641)
* fix(invoices): surface validation errors instead of a silent dead submit button

A missing unit (or any other Zod failure) blocked both Granska & skapa and
Spara som utkast with zero feedback: handleSubmit had no onInvalid callback,
the buttons stayed enabled, and the unit field rendered no inline error.
Reported by a byra user whose client could not save any invoice.

- onInvalid handler on all three submit paths: destructive toast plus scroll
  to the first inline error
- inline error text under the unit select and quantity input (the only line
  fields that had none)
- same treatment in NewRecurringScheduleDialog, including inline errors on
  its item rows

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

* fix(supplier-invoices): stop defaulting 25 % moms for icke momsregistrerade companies

The registration form hard-coded vat_rate 0.25 on the initial line, added
rows, AI prefill fallback and konto defaults, regardless of
company_settings.vat_registered. A non-VAT-registered business that missed
the prefilled rate booked ingaende moms (2641) it has no right to deduct
(ML 8 kap. 3 \u00a7). The customer-invoice side already gates on the same flag;
the supplier side ignored it.

- form: read vat_registered from /api/settings; when false, all moms
  controls (rate cells, per-line moms, totals rows) are hidden and every
  line is forced to 0 %, including late AI prefills
- reverse charge keeps its rate controls: self-assessment is a separate
  obligation from deduction
- route: 400 SI_CREATE_INVALID_INPUT when a non-registered company posts a
  line with vat_rate/vat_amount > 0 (API/MCP defense in depth), and an
  omitted vat_rate now defaults to 0 instead of 25 % for those companies
- tests: guard rejection, reverse-charge pass-through, 0-default; existing
  POST tests updated for the new settings lookup

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

* feat(year-end): klarmarkera imported years already closed in a previous system

SIE-imported historical fiscal years land with is_closed = false and no
closing entry, so the year-end page lists every migrated year as pending
bokslut even though the bokslut was done in the old software. There was no
sanctioned way to mark them done: closePeriod hard-requires locked_at and
closing_entry_id.

- migration: fiscal_periods.closed_externally boolean (audit clarity:
  distinguishes a year-end run here from a close done elsewhere)
- markPeriodClosedExternally(): closes + locks without a closing entry;
  refuses already-closed periods, periods with their own closing entry,
  periods that have not ended, and periods with unbooked bank transactions
  (same stranding guard as lockPeriod); writes the immutable audit_log entry
- POST /api/bookkeeping/fiscal-periods/[id]/close-external (requireWrite)
- year-end page: one attn line on the preflight step with a confirm dialog
  describing the outcome; the marked year drops out of the eligible list

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

* feat(invoices): searchable article picker on invoice lines

The article field was a plain Radix Select whose only matching is
label-prefix typeahead: for numbered articles that means number-only lookup,
and typing "skruv" found nothing. Byra feedback: name search would help a
lot for users with real article catalogs.

New ArticleCombobox (input-trigger dropdown, same pattern as
AccountCombobox): free-text search over name + article number,
diacritics-folded via foldText, keyboard navigation, pinned "Egen rad"
free-text option, browse-all on focus like the Select it replaces.

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

* docs: log klarmarkera pg-test decision

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

* fix: address skeptic and compliance-review findings on PR #1641

- ArticleCombobox: keyboard focus no longer auto-opens the list, opening
  highlights the committed selection, typing highlights the first match,
  and re-selecting the current value is a no-op. Previously Tab+Enter
  silently detached the article and wiped its revenue-account override.
- Supplier invoice prefill for icke momsregistrerade: the zeroing effect now
  grosses the net amount up by the extracted rate before forcing 0 %, so the
  booked cost and 2440 keep the full att-betala amount instead of
  understating both by the moms.
- markPeriodClosedExternally: only migrated periods qualify (must contain
  SIE-imported verifikat or no verifikat at all); the update carries an
  is_closed=false predicate so a concurrent normal close cannot be
  overwritten; confirm dialog now names the reporting consequences.
- Route comment: honest scope (this route only; v1/inbox/MCP sweep is a
  follow-up) and current-law citation (13 kap. ML 2023:200).

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

* fix: use roundOre for the icke-momsregistrerad gross-up (ratchet guard)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:02:43 +02:00
Mattsson f8db38f989 fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask

Invert PostHog session-replay masking from visible-by-default with pattern
masking to deny-by-default: every input value is masked wholesale (rrweb
maskAllInputs, no maskInputFn) and every text node is masked unless it sits
under data-ph-unmask chrome or a table column header (th). Chrome tags live
on the shared UI primitives (PageHeader, Label, Button except combobox
triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help
popovers, empty states, settings labels), and tagged chrome is still
pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask
beats data-ph-unmask, so call sites that interpolate user data into chrome
stay masked; a very-thorough audit swept every unmasked primitive and each
found site got a call-site mask. Confirm-dialog wrappers and toasts stay
masked centrally: their copy describes user objects by design. Untagged new
UI over-masks instead of leaking. Privacy policy, RoPA and decision log
updated in the same change.

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

* fix(analytics): tag detail-section chrome merged from main

The register-detail primitives landed on main after the replay-masking
audit ran: kickers and DefRow labels are static i18n chrome, values stay
masked.

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

* fix(analytics): close skeptic and review findings on replay masking

Explicit data-ph tags now resolve before the th chrome fallback, so a th
nested inside a data-ph-mask container masks correctly (regression test
added). Seven missed text-leak sites get call-site masks: delete-invoice
and credit-page invoice numbers, IB-correction voucher reference, TIC
orgnr (served unnormalized, so the separator-based scrub cannot be relied
on), articles search-term empty state, dimension segment labels, and
activate-account buttons. The attribute channel is closed with rrweb's
blockClass: inputs whose placeholder carries an effective user value
(salary overrides, correction description, danger-zone confirms, credit
confirm) get ph-no-capture, removing the element from recordings while
the prefill UX stays intact; the pivot-th title attribute is dropped.
Privacy-policy effective date bumped to 2026-08-17.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:32:45 +02:00
Mattsson 1bb423b2b3 fix(salary): surface missing sender bankgiro/IBAN before betalfil download (#1640)
* fix(salary): surface missing sender bankgiro/IBAN before betalfil download

Users see a bankgiro under BANKUPPGIFTER in settings (Bolagsverket
snapshot, display only) while the payment-file routes read
company_settings.bankgiro, so the LB download failed with an error
that pointed at a page that looked correct. 153 companies have a
registry bankgiro but an empty settings field.

- PaymentFilePanel warns up front when the sender bankgiro (bg_lb)
  or IBAN (pain001) is missing, linking to Installningar -> Fakturering
- betalkonton form offers a one-click prefill of the bankgiro from
  companies.tic_snapshot (Luhn-validated, user still saves)
- bg-lb and skattekonto payment-file error copy now names the exact
  place to fix instead of 'foretagsinstallningar'

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

* fix(salary): harden bankgiro prefill and warning per skeptic review

- bankgiroFromTicSnapshot now requires the snapshot's orgNumber to match
  companies.org_number before suggesting anything: stale fuzzy-matched
  snapshots can hold another entity's profile, and this field becomes the
  payee account on invoices and Peppol e-invoices
- salary run page refetches settings when the URL returns from the
  intercepting settings modal, so a bankgiro/IBAN saved there clears the
  missing-sender warning instead of leaving it stale

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:15:37 +02:00
Jakob Wennberg 25524e1df4 fix(suppliers): stop requiring standardkonto that was never meant to be required (#1636)
* fix(suppliers): stop requiring standardkonto that was never meant to be required

The supplier form initializes every optional field to '' and sent them
as-is, while CreateSupplierSchema validates default_expense_account
with the 4-digit account rule behind .optional(): an empty string is a
present string, so saving a supplier with the field untouched failed
with "Kontonummer måste vara 4 siffror" even though the field carries
no required mark (reported by Björn with a screen recording; the edit
page failed the same way for any supplier without a default account).

Schemas now own the normalization, split by verb: on create '' becomes
undefined (key dropped, column NULL), on update '' becomes null,
because update routes pass fields straight into .update() where
undefined means "leave unchanged" and clearing must actually write
NULL. Email gets the same treatment and the form's old client-side
email strip is removed; stripping empty strings client-side would
break exactly the clear path.

The free-text Standardkonto input is replaced with the shared
AccountCombobox (browsable list filtered to cost classes 4-7, the same
rule the agent-path expenseAccountField enforces), with the selected
account name shown under the field and a clear button when set.
Standardkonto itself stays optional: it only prefills supplier-invoice
lines and the ledger-context suggestion covers the empty case.

Verified end to end against the running app: saving a supplier without
a default account succeeds on the update path, and the combobox
search/select/clear cycle works inside the create dialog.

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

* fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance

The minimal Zod-to-JSON-schema walker described every pipe by its input
side. For .transform() that is right (the caller sends the input), but
z.preprocess() is the mirror image: the callable sits on the input side,
so the supplier schemas' new empty-string normalization rendered email
and default_expense_account as required untyped fields in the OpenAPI
spec and the generated accounted-api skill. Describe the output side
when the input is a transform.

Required-ness now derives from schema.safeParse(undefined) instead of a
top-level discriminator check: a field may be omitted exactly when the
schema accepts undefined. Besides the preprocess pipes, this corrects
several fields the old check misrendered as required (z.unknown()
bodies, union-with-empty-string settings fields, preprocessed
personal_number), so the regenerated skill references only flip
required to optional where runtime validation already allowed omission.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:41:03 +02:00
Jakob Wennberg 2eb3441244 fix(export): paginate the archive size estimate and explain scope counts (#1635)
The period branch of estimateArchiveSize ran a single unpaginated
document read with one flat IN() over every posted entry id in the
year: past the PostgREST row cap it silently undercounts, and past a
few hundred entry ids the URL itself blows up. Chunk the id filter
(CHILD_FK_CHUNK) and paginate every read with fetchAllRows, mirroring
what writeDocuments already did (the ZIP content was never affected).

The dialog now says per scope which documents are counted: full
history includes unlinked inbox/receipt documents, a single year only
those linked to posted vouchers. Without that line, a company with
many unlinked receipts reads the count gap as a pagination bug.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:20:18 +02:00
Jakob Wennberg 62c6fc44fe fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim (#1634)
* fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim

Two gaps reported by a user invoicing RUT work:

- Picking an article with a housework_type (arbetstypskod) left the line's
  skattereduktion on 'Ingen': the editor never fetched the field. applyArticle
  now derives deduction_type from the code's Skatteverket list (disjoint ROT/
  RUT lists, new deductionTypeForWorkType helper) and sets work_type, with the
  same overwrite semantics as description/price: an article without a code
  clears the deduction so a material article never keeps claiming one.
  'Spara som artikel' round-trips the code back onto the created article.

- The customer card's personnummer was never used for the ROT/RUT claim; the
  user had to retype it per invoice. The browser only ever sees ciphertext or
  a mask, so the fix is a server-side fallback in buildInvoiceWriteData:
  typed > stored draft > kundkort. The kundkort value is decrypted, expanded
  to 12 digits (new expandPersonnummerTo12, century inference incl. '+' and
  samordningsnummer), Luhn-validated, and encrypted into the invoice; invalid
  or unreadable values fall through to the existing 'Personnummer krävs'
  error. The editor drops the required-mark and hints that the number comes
  from the kundkort when one exists.

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

* fix(invoices): gate the kundkort personnummer fallback on individual customers

ROT/RUT is a privatperson deduction; customers.personal_number is
individual-only in the Zod schemas but not in the DB, so a stray value on a
business row must never be claimed on implicitly. Typed values unaffected.
Raised by the compliance review bot on #1634.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:06:05 +02:00
Jakob Wennberg 44c3116357 feat(export): direct download of the complete archive from the Exportera tab (#1632)
The full-archive ZIP endpoint (SIE + reports + all documents) has existed
since the settings/backup page, but lost its UI when that page became a
redirect: the BackupDownloadForm component was orphaned and the download
was API-only. Resurface it the way the export tab already works: a
"Komplett arkiv" ImportRow (owner/admin only, matching the route's role
gate) opening a small centered dialog like the SIE export next to it,
with scope choice, fiscal-year picker, include-documents toggle, live
size estimate, 413 handling, and a #full-archive deep link.

The orphaned form and its dead settings_backup_download i18n namespace
are deleted; its logic lives on in components/import/FullArchiveDialog.
Over-limit copy now points at the existing cloud sync instead of
promising it "in a later version".

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:49:13 +02:00
Jakob Wennberg c897a906df fix(ux): actions update lists in place - no more takeovers, jumps and dead air (#1629)
* fix(ux): update lists in place on actions instead of takeover spinners and jumps

Founder report: the app feels glitchy when clicking around, especially
when deleting a row or booking something. The repo-wide anti-pattern
behind it: single-row actions trigger whole-list skeleton/spinner
takeovers (layout collapse, scroll jump, full stagger-enter replay),
deletes give zero feedback then hard-jump, and the /transactions exit
"animation" was filter-only and never animated.

Per surface:

- Never take over a rendered list for a background refresh. The
  skeleton/spinner swap is now reserved for an empty (or foreign) list
  on /transactions (fetchTransactions), /pending (fetchOperations,
  covering both listed Granskning findings, one file), kundfakturor
  (fetchInvoices), leverantörsfakturor (fetchInvoices, plus
  try/catch/finally so a failed fetch can no longer stick the skeleton
  or masquerade as an empty register) and the verifikat list
  (JournalEntryList now takes a refreshToken prop and refetches in
  place; /bookkeeping no longer key-remounts it into a spinner, so
  expansion/selection/pagination/scroll survive a created verifikat).
  Quiet inline Loader2 cues near the list headers on /transactions and
  /pending signal a background reconcile.

- /transactions row exit: exiting rows (booked/ignored/deleted) stay
  rendered through the existing 350ms window with a real exit
  transition (.row-exit: fast fade, then the space closes by
  transitioning cell paddings/line metrics and a numeric max-height on
  the fixed-height cell spans) and pointer-events off. Instant removal
  under prefers-reduced-motion. Applied to the inbox cards, the
  skattekonto card and the history rows.

- /transactions delete: routes through processingId (row spinner) and
  the exitingIds path, and decrements totalUncategorizedCount when the
  deleted row was pending (the realtime echo is not guaranteed for
  DELETE on a filtered subscription).

- FyPicker double-fetch: the initial fetch now waits for FyPicker's
  onReady (fires after its persisted-scope restore), so mount does one
  correctly scoped fetch instead of racing an unscoped fetch against
  the restore refetch (list -> skeleton -> list on every visit). Period
  changes refetch background-only behind the client-filtered list.

- Pagination survives realtime echoes: background refreshes re-fetch
  range(0, pagedCountRef) instead of resetting to the first 200 rows,
  so "Visa fler" pages no longer collapse after any action.

Gates: full vitest suite green (14764 passed), tsc output byte-identical
to the origin/main baseline, eslint 0 errors on touched files,
check:guards green, package-lock untouched.

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

* fix(ui): apply review round on action-feedback smoothness

- /pending: sequence-guard fetchOperations so a stale previous-tab
  response can't overwrite the current tab's rows, counts, or loading cues
- /pending: check res.ok on the pending fetch and both history fetches
  before applying payloads; failures keep current rows and surface the
  existing error toast
- /transactions: reset fiscal scope (fyReady/fyPeriodId/fyPeriod) during
  render on company switch so FyPicker re-runs its persisted restore and
  stale bounds never scope a fetch for the wrong company
- /transactions: drop a deleted row's id from selectedIds so the bulk bar
  can't act on a deleted row
- row exit: add the inert attribute on exiting row wrappers alongside
  pointer-events so keyboard focus and activation are blocked too
- JournalEntryList: preserve selection on refreshToken background
  refreshes (reconciled against the refreshed page); user-initiated
  reloads still clear it

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 18:44:02 +02:00
Jakob Wennberg 4dbd19aeb0 fix(transactions): the underlag column is one surface, not a dropzone and an orphan button (#1628)
* fix(transactions): the underlag column is one surface, not a dropzone and an orphan button

The Bokfor transaktion dialog stretched its empty dropzone into a
45/72vh well and pinned "Valj befintligt underlag" alone at the very
bottom of the column, visually disconnected from the dropzone it
belongs to.

- Empty state: the underlag column now sizes to its content and
  top-aligns; the inbox picker renders as a quiet full-width dashed
  row directly under the dropzone ("eller valj befintligt underlag
  fran inkorgen"), so drop-a-file and pick-from-inbox read as one
  intake surface. The fixed-height sticky column returns as soon as
  a document previews there (uploaded, picked, or pre-linked).
- Grid rebalanced from 1fr/520px to 2fr/3fr so the kontering side
  dominates while nothing is being previewed on the left.
- QuickReviewDialog gets the same footer-row treatment for its picker
  trigger inside the underlag collapsible, so both #1620 surfaces
  present the affordance identically (disabled-while-booking kept).

Presentation only: upload path, select-mode picker held until booking,
linkDocuments with inbox_item_id, picked-state resets on close and
transaction change, and duplicate-match wiring are all unchanged.

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

* docs: record the QuickReviewDialog picker-trigger consistency decision

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 18:06:53 +02:00
Jakob Wennberg a977a67063 refactor(register): customer, supplier and article detail as documents, not card piles (#1624)
The three register detail pages rendered a handful of facts as a grid of
sparse bordered cards floating in an empty page. They now read as one
flowing document: serif entity name over a quiet type/status kicker,
quiet ghost actions top right (delete turns terracotta only on hover),
and hairline-kickered definition sections (KONTAKT, KUNDUPPGIFTER,
PRIS, BOKFORING, BETALNINGSUPPGIFTER, FAKTUROR) with aligned label/value
rows in a constrained column (max-w-2xl, supplier max-w-3xl for its
invoice table). Sections land with the standard stagger-enter.

New shared primitive components/ui/detail-section.tsx (DetailSection,
DefRow, DefEmpty) carries the grammar. Empty values render a muted
en dash for facts that matter (email, phone, expense account) and are
omitted row-wise otherwise; a section with nothing to say is omitted.

Behavior preserved: edit/deactivate/delete flows, confirm dialogs,
personal-number reveal + AttnLine, account-activation retry, viewer
lock states, routing and all existing i18n keys. New def_* label keys
added line-wise to both sv.json and en.json; customer invoice_count
gained ICU plural (1 faktura, not 1 fakturor).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:28:45 +02:00
Jakob Wennberg 51539b93ed fix(salary): one save per surface on the employee form (#1623)
* fix(salary): one save per surface on the employee form

The employee edit page stacked two competing saves: the opening-balances
Card ended in "Spara ingaende saldon" and the page ended in "Spara
andringar" 80px below, with no visible boundary between their scopes.
Worse, both self-saving panels lived INSIDE the page <form> and shadcn
Button sets no default type, so every panel button (save opening
balances, add/remove benefit) implicitly submitted the outer form too,
firing the full employee PATCH alongside the panel's own request.

Restructure so each surface owns exactly one save:

- The employee <form> now closes right after the Bank card, with
  Avbryt + "Spara andringar" directly under the fields it actually saves.
- Formaner and Ingaende saldon move below the form into a "Sparas
  separat" section (uppercase kicker + one-line scope hint) so the page
  save structurally cannot include them and their buttons can no longer
  leak submits into the employee form.
- OpeningBalancesPanel becomes its own <form>: Enter saves the panel,
  and the save button enables only when its fields are actually dirty
  (fingerprint of loaded values, reset on successful save).
- EmployeeBenefitsPanel buttons get explicit type="button".

New strings in both messages/sv.json and messages/en.json.

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

* fix(salary): release the loading skeleton when the balances fetch fails

CodeRabbit on #1623: a rejected fetch or JSON parse skipped the
setLoading(false) line, holding the skeleton forever. The load now
wraps in try/finally; a failed load falls back to the empty form.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:04:57 +02:00
Jakob Wennberg dd4ced1f93 feat(import): the constellation breathes between beats (#1622)
The theater canvas froze visually between spawn events; long holds like
"Skriver till journalen..." read as stale. Add continuous ambient life
inside the existing rAF loop, derived entirely from the clock (no extra
timers), without inventing progress: motion means the system is alive,
not that work completed.

- Per-node breathing: radius +-10% (about 1px on the hub) plus up to 4%
  alpha, on two slow incommensurate clocks offset by each node's own
  position/wave phase so the field shimmers organically, not in sync.
- Quiet ripple: every 7s a luminance wave travels hub to rim over 2.6s,
  brightening the hairline year rings (+0.18 alpha peak) and edges
  (+0.12) it passes. Alpha only: no color change, so it cannot be
  mistaken for the sage event pulse.
- Settled mode (result reveals) rests at half breathing amplitude and
  gets no ripple; the reveal is a verdict.
- prefers-reduced-motion: ambient scale is zero and the frame stays
  frozen as before.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:59:22 +02:00
Mattsson 86f0b70fdd fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:45:04 +02:00
Mattsson edfdbe2d2a fix(auth): move the BankID flow into a signed, user-gated, single-use cookie (#1625)
* fix(auth): move the BankID flow into a signed, single-use, confirm-on-resume cookie

A user's BankID signup identified successfully four times and created no
account. His screenshots show four tabs, one on the finished "Verifierad med
BankID, ange e-post" step, and the tab he was looking at showing the idle
button. Prod agreed: no bankid_identities row, no auth.users row.

On iOS outside plain Safari the BankID return URL is handed to the OS, which
opens a NEW tab. The session lived in per-tab sessionStorage, so that tab
started empty and rendered the start button while the completed flow sat
stranded. Login hid it (self-finishing, cookie-backed session); signup waits
for a human to type an e-mail into the stranded tab, so it dies there.

The session id is no longer handed to the browser. It lives in a signed
__Host- HttpOnly cookie set at /start; /poll, /complete, /link and /cancel
read it. Cookies are shared by every tab of the origin, which is what the
handoff needed. The id had to leave the client because it is an
unauthenticated bearer credential: /poll was skipAuth and returned
user.personalNumber, and /complete with mode 'login' returns a tokenHash that
verifyOtp turns into a session, MFA skipped for bankid_linked accounts.

A completed identification must never be consumed by whoever merely opens the
page. A shared cookie plus a shared machine means the tab that finds a
completed flow cannot prove the person at it is the one who made it, and no
client-side token can prove otherwise: nothing survives an iOS same-tab reload
yet dies on reopen-closed-tab / session restore / tab duplication. So a resume
is never automatic. The mount probe routes any found live flow to a confirm
card ("Fortsätt bara om det var du") that reveals no name, and only that click
polls and consumes. Auto-consume happens only inside the live component
instance that called startSession (desktop QR; the pre-navigation mobile
launch), which by construction is the originator. Cost: one tap after
returning from the BankID app on iOS, exactly where the reported bug lives;
desktop and Android never hit the resume path.

The rest is defence the four review rounds proved load-bearing:
- __Host- with Path=/ and unconditional Secure, so a script cannot plant the
  same name at a longer path; readBankIdFlow fails closed on duplicates and on
  a malformed percent-escape.
- Single-use is a unique index (bankid_consumed_sessions), claimed before
  generateLink, not a Set-Cookie. Fail-closed on any non-23505 error, so the
  migration MUST be applied before the code.
- A link flow requires auth at /start and pins userId; /link rejects a flow
  owned by anyone else, before any TIC call. mode is pinned and /poll rejects a
  body mode that does not match, so a login session cannot finish through the
  signup panel. /poll withholds the holder name from a probe. The 900s
  verified-step window is capped by MAX_TOTAL_LIFE from a signed startedAt.
  /poll never clears the cookie (an untargeted Set-Cookie would delete a newer
  flow); only /cancel and terminal /complete + /link exits clear. Avbryt holds
  a 'cancelling' state until /cancel resolves so a new /start cannot race the
  clear. Session id is logged only as an 8-char prefix.

The launch is untouched: iOS keeps its return URL, Android keeps redirect=null
(#194 closed that path deliberately).

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

* fix(auth): bind BankID actions to the resumed flow

* docs: record BankID staging migration drift

* fix(auth): address BankID PR review

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:07:29 +02:00
Mattsson 2deea05d42 feat(import): attach underlag to SIE-migrated verifikat by filename (#1627)
* refactor(documents): lift the SIE voucher-ref resolver into core

The provider migration sweep resolved a source voucher reference to the
verifikat it became with an in-memory (period, series, number) index built
inside extensions/general/arcim-migration. The underlag filename import needs
the identical resolution, and core must never import from @/extensions, so the
index, its ambiguity handling and the two paged reads move to
lib/documents/voucher-ref-resolver.ts.

Behaviour-preserving for the extension: same index construction, same "drop
both when one key repeats inside a fiscal year" rule, same dateTo-window
resolution. The arcim tests pass unchanged.

Two deliberate additions on top of the lift:
  - series comparison is now case-insensitive on both sides. SIE writes series
    uppercase in practice but the spec does not require it, and a filename is
    whatever the exporting tool produced.
  - byNumber and fetchVouchersForNumbers serve the filename flow, which
    resolves a handful of refs per request and must not pull every migrated
    entry into memory to do it.

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

* feat(import): attach underlag to SIE-migrated verifikat by filename

A SIE file carries the ledger but not the underlag, so a migrating customer
brings the receipts over separately and today has to open every verifikat and
attach them by hand. Systems that export both name each receipt after its
verifikat (A31_<internal-id>.pdf), and the SIE import already preserves that
identity on every entry (source_voucher_series / source_voucher_number), so
the pairing is a lookup, not an interpretation: no AI, no amount matching, no
date windows.

Separate optional import mode (/import?mode=underlag), NOT a step inside the
SIE wizard: the receipts normally arrive later and from a different export, so
a migration must never be blocked on having them ready.

  lib/documents/filename-voucher-ref.ts  reads the ref out of a filename
  lib/documents/underlag-import.ts       builds the plan (reads only)
  POST /api/import/documents/preview     filenames in, match plan out
  POST /api/import/documents/attach      one file, archived and linked
  components/import/UnderlagImportWizard review, adjust, run

Guards, because a document linked to a posted verifikat is
räkenskapsinformation and can never be re-pointed (BFL 7 kap):

  - Matching keys on the SOURCE voucher number, never our own. The importer
    renumbers per target series, so a file named after our number would land
    on the wrong verifikat exactly when the import skipped a voucher.
  - Nothing is uploaded until the whole plan has been shown: the preview
    sends filenames only, the bytes stay in the browser.
  - A ref that hits several migrated years is surfaced as a choice, never
    resolved by guessing. So is a filename with a number but no series, which
    is resolved but never pre-selected.
  - A date-named file (20240131.pdf) is refused outright rather than read as
    voucher 20240131.
  - A target in a closed or locked period is shown but not selectable:
    enforce_period_lock_documents would refuse the write anyway.
  - The attach route re-resolves the filename server-side and 409s when it
    does not name the target the client sent, so a stale plan cannot scatter
    underlag permanently. An explicit manual assignment opts out of that check
    and is flagged as such; company ownership of the entry is always verified.
  - Idempotent per (verifikat, content): a re-run converges on the same
    document row instead of archiving duplicates.

tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract
the plan surface promises, including that the lock guards the LINK and still
lets an unlinked document be archived.

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

* fix(import): scope underlag matching to a declared fiscal year

Adversarial review of #1627 refuted the resolver: it looked a ref up
company-wide and treated "exactly one candidate exists" as proof of identity.
Source systems restart voucher numbering every year and a filename carries no
year, so with a partial migration, or with that year's A31 among the vouchers
the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt
was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and
invisible afterwards. Cardinality is not identity.

Every batch now declares its fiscal year and candidates outside it are dropped
before the index is built, so no downstream branch can see, count or propose
one. The attach route takes the year for its re-resolution from the TARGET
entry, never from the client, so the check cannot be widened by naming a
different year. Scoping cannot make the year inferable; it makes it asserted,
and the confirm dialog reads it back because it is the one input the files
cannot corroborate.

Four further defects from the same review:

  - npm test went red: hoisting the column list into a VOUCHER_SELECT constant
    hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and
    dropped all eight journal_entries columns out of the guard on the one path
    that writes irreversible links. Both selects are inline again, and split:
    the provider sweep no longer fetches three display columns it never reads.
  - The date guard only caught zero-padded hyphenated dates, so
    `2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all
    parsed as voucher 2024 or 24. Widened to unpadded components, two-digit
    years and space/slash separators; a bare year-shaped number is refused.
  - `Verifikation 31.pdf` parsed as series ION: the alternation matched
    `ifikat` and left `ion` for the series group. Reordering alone was not
    enough (the engine backtracks into it), so the prefix now requires the
    word to end.
  - The manual-reference box was an unguarded write path: typing a date got
    path-split down to a voucher number, marked the row selected, and posted
    with override, which skips both server checks, while the row still showed
    "Kan inte tolkas". Directory splitting is gone from the parser, the row
    status is updated on resolve, and picking a server-proposed candidate no
    longer counts as an override, which had disabled the filename check on
    exactly the ambiguous rows it exists to protect.

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

* fix(import): enforce the declared fiscal year on the server

The second adversarial pass refuted the previous fix. The attach route took
the year for its re-resolution from the TARGET entry, which is tautological:
an entry is by construction inside its own fiscal_period_id, so the filter
could never drop it and the year axis was unfalsifiable. Server-side year
enforcement was zero; the declared year existed only as React state and was
never sent. The regression test that "proved" otherwise passed only because
the mock let one journal_entries row report two different fiscal_period_id
values to two different reads, a state Postgres cannot produce. A test that
could not fail.

The attach request now carries the year the user actually reviewed, echoed
back from the plan, and the route asserts it equals the target's own period
BEFORE any other check and including overrides: an override is a statement
about which verifikat, never about which year. Its test asserts that directly
instead of a mock artifact.

Also from the same pass, a UI race that made the confirm dialog lie: FyPicker
stayed interactive while a preview of up to 2000 filenames was in flight, so
the summary and the confirm text could read back a year the plan was not built
from, and a manually resolved row could join the batch from another year
entirely. The wizard snapshots the plan's year, every downstream read uses the
snapshot, manual re-resolution goes through the server's own echoed
plan.fiscal_period_id, and the picker is frozen while a preview runs.

Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids,
no ReDoS found: 2000 hostile inputs in 26ms):

  - Day-first and US dates parsed as voucher numbers: `31.01.2024` became
    voucher 31, a number that always exists in the year. The guard now covers
    both orders.
  - `ver 31.pdf` parsed as series VER and came back auto-selectable, while
    every spelled-out `Verifikat 31.pdf` correctly yielded a series-less
    reference needing confirmation. Same filename, two trust levels, decided
    by an abbreviation. `ver` is no longer a series.

Known residual, stated rather than papered over: a scanner's `A4.pdf` or a
`K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10
when that year has them. No parser can separate those from a genuine
reference; they appear in the review table with the target's date and
description.

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

* fix(import): make the user actually declare the fiscal year

The third adversarial pass found that the central guarantee of the previous
two commits was fiction. FyPicker auto-selects the newest fiscal period when
nothing is stored, and the wizard passes a page-specific storage key, so that
branch fired on every first use. A user migrating 2023 receipts who never
opened the picker resolved them against the newest year; A31 exists in
essentially every year, so those rows came back `matched`, pre-selected, with
only the confirm dialog between them and permanent links. Every commit message
and code comment claiming "the year the user named" described behaviour the UI
did not have.

FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other
caller changes, and the wizard uses it. The picker starts empty and the batch
cannot proceed until someone picks. A previously stored explicit choice for
this surface is still restored, which is what makes a multi-batch migration
bearable.

Also: a company with zero fiscal periods hit a disabled picker and a disabled
button with no explanation. There is now a line saying why.

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

* fix(import): close the restore-branch hole and demote collision-prone refs

Round four of adversarial review, two findings, both fixed.

1. `requireExplicitChoice` gated only the newest-period fallback, not the
   localStorage restore branch above it, so the "user declares the year"
   guarantee held only for a user's first-ever batch. From the second on, the
   year was silently pre-filled from an earlier unrelated batch, and in a
   multi-year migration last-used is the worst possible default: the user is
   by definition moving to a different year each round. The prop now gates
   FyPicker's ENTIRE auto-selection block with one outer condition (restore,
   the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a
   per-branch gate already missed one branch once. It also suppresses the
   localStorage write, which fired BEFORE onChange and so recorded picks the
   wizard had rejected mid-preview. The wizard drops its storage prefix
   entirely: within one sitting reset() carries the year in state, and
   nothing survives the session.

2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring
   a click for `31.pdf`, which carries MORE voucher evidence in a
   single-series company. Two independent review passes flagged the same
   inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/
   T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/
   SCN are cameras; real SIE series are 1-2 chars) still parse and resolve
   but are never auto-selected. Demoted, not refused: verifikat A4 genuinely
   exists in every migrated ledger, and its real receipt costs one click.
   Residual documented: an existing short series plus a small number in an
   ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by
   filename alone.

Also: the attach route's multipart doc now names the required
fiscal_period_id field, and the stale reset() comment describes the actual
persistence model.

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

* fix(import): honor override only for unresolvable filenames + review round

Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish
accounting review, compliance swarm).

The one substantive finding (CodeRabbit, major): `override: true` skipped the
filename consistency check entirely, so a crafted client could attach a
cleanly-named file to any same-year verifikat. The resolver now runs on every
request; an override is honored only when the filename is unresolvable in the
declared year (no parse, or no candidate) or already resolves to the requested
target. The shipped UI only overrides unresolvable rows, so nothing
user-facing changes. planAcceptsTarget is renamed planPermitsAttach and
carries the semantics in one place, with tests for both directions.

The Swedish review finding (BFNAR 2013:2 systemdokumentation): the
planPermitsAttach JSDoc still described the superseded derive-the-year-from-
the-target design. It now states the actual control: the route asserts the
caller-declared year equals the target's own period before this function runs.

CodeRabbit minors and nitpicks:
  - underlag_confirm_body / underlag_run / underlag_locked_warning use ICU
    plural forms in both locales; "1 filer arkiveras" was wrong Swedish.
  - The attach and preview route tests mock @/lib/supabase/server per the
    repo test guideline.
  - fetchVouchersForNumbers narrows to the declared fiscal year at the DB;
    the in-memory filter in buildUnderlagPlan remains the enforced truth.
  - buildVoucherIndex appends into existing arrays instead of copying per
    row: the provider sweep indexes every migrated entry in the company and
    per-row copies made that O(n^2).
  - The pg test reuses its insertDocument helper instead of a duplicated
    INSERT; runAttach clears isLoading in a finally.

Declined, with reasons in DECISIONS.md: message-regex classification of
validateDocumentFile failures (established sibling pattern; validator
contract change is out of scope).

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

* fix(import): attach only to posted or reversed verifikat

Second review cycle on PR #1627: the Swedish accounting review's re-run found
that nothing in the attach route verified the target entry's status. The SIE
import RPC posts every entry inside its own transaction, so a draft carrying a
source ref should be unobservable, but the link this route writes is
irreversible räkenskapsinformation, and an invariant enforced in another file
is not one this surface may lean on. Underlag references a verifikation
(BFL 5 kap 6-7 §), so the target must BE one.

Enforced twice: the route rejects non-posted targets with
UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter
to posted/reversed so a draft can never even become a candidate. Reversed
stays attachable: a storno'd original remains räkenskapsinformation and its
underlag belongs on it.

Also recorded as confirmed-intentional (review note, no code change): with
override and an unresolvable filename the endpoint links to any same-company,
same-declared-year, posted verifikat, migrated or not, which mirrors the
existing /api/documents/[id]/link capability. The period-lock error-string
regex note restates a disposition already recorded in DECISIONS.md.

The arcim test's Supabase double learns .in(), which the shared resolver read
now uses for the status filter.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:50:32 +02:00
Mattsson 4362bffc0c fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat (#1621)
* fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat

"Skapa verifikat manuellt" in the SkattekontoBookDialog routed to plain
/bookkeeping: the user landed on the list with no form, no prefill and no
link to the row (reported by a user for a Slutlig skatt event, which has
no booking rule by design).

The CTA now deep-links to /bookkeeping?skv_tx=... carrying the row's id,
date, text and amount. The bookkeeping page opens the Nytt verifikat
dialog prefilled (1630 on the correct side per the booking sign
convention, balanced counter line with the motkonto left to pick, date
and description set) and, once the verifikat is saved (posted or draft),
links it back to the skattekonto row via the existing match endpoint. A
failed link degrades to a destructive toast pointing at the manual
"Matcha mot verifikat" path.

The URL params are prefill convenience only: the match route re-validates
ownership, ALREADY_BOOKED and ENTRY_ALREADY_LINKED server-side. The
parse/build/line-shaping contract lives in core lib
(lib/skatteverket/manual-verifikat-prefill.ts, unit-tested) because the
bookkeeping page cannot import from the extension.

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

* fix(skattekonto): keep deep-link payload out of the URL + share the 1630 constant

Resolves the PR #1621 review findings in one pass:

- Compliance swarm (GDPR Art.5(1)(f), ISO A.8.12): the deep link no longer
  carries date, text and amount as query params, where they would persist
  in browser history, access logs and Referer headers. The row payload is
  staged in sessionStorage, consumed single-use and validated against the
  opaque skv_tx id, which is all the URL exposes. A missing or mismatched
  payload degrades to the plain /bookkeeping list; the auto-link itself is
  still validated server-side by the match route.
- Swedish accounting review note: SKATTEKONTO_ACCOUNT ('1630') is now
  imported by the extension's booking and match libs from the core prefill
  lib instead of being duplicated, so prefill and server-side booking
  cannot drift.
- CodeRabbit docstring warning: the new lib exports carry docstrings.

Storage is injectable (PrefillStorage) so the node-env tests cover the
round-trip, single-use semantics, id mismatch, malformed payloads and a
throwing privacy-mode storage.

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

* docs(skattekonto): record the sessionStorage staging window as accepted residual risk

The compliance swarm's remaining LOW finding (ISO A.8.12) offers
documentation as its remediation path: an XSS attacker already reads the
full ledger via the session's authenticated APIs, so the sub-second
sessionStorage staging window adds no capability worth a server-issued
token roundtrip. Recorded in the lib header and DECISIONS.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 15:10:06 +02:00
Jakob Wennberg 6404591b89 fix(import): parse the SEB Transaktioner CSV layout (split Insättningar/Uttag) (#1616)
* fix(import): parse the SEB Transaktioner CSV layout (split Insättningar/Uttag)

The SEB profile only understood the Kontoutdrag export layout. The
Transaktioner page (the path most users find first) exports a different
header: Bokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo,
with dot decimals and the amount split across two columns. No profile
detected it, so auto-detection found nothing and an explicit SEB choice
failed on column detection.

Teach the SEB profile the layout: detect on the Insättningar/Uttag pair
(unique among supported formats), accept Bokförd as a booking-date
column, and combine the split amount (Uttag carries its own minus;
unsigned magnitudes are normalized to expenses). Fixture header and
first data row are verbatim from a user-provided export. The import
help text now lists both SEB export paths.

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

* docs: decision log for SEB Transaktioner parser design

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:05:22 +02:00
Mattsson 3036c3d879 feat(transactions): pick underlag from the inkorg in quick review (#1620)
* feat(transactions): pick underlag from the inkorg in quick review

The quick-review dialog reached from "Att göra -> Bokföra transaktioner"
only offered a local-file dropzone, so a receipt already sitting in the
underlagsinkorg had to be re-uploaded. That also left the inbox item
unconsumed, leaving a duplicate in "Underlag att hantera".

Wire InboxDocumentPicker (select mode) into the Underlag section, next to
the upload zone, mirroring TransactionBookingDialog. Picked documents are
held until the booking returns a verifikat, then linked through the
existing linkDocuments call with their inbox_item_id, which stamps the
inbox item as consumed.

Picks reset on close and when the dialog moves to another transaction, so
a document picked for one row can never attach to another row's verifikat.

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

* fix(transactions): clear inbox picks when the underlag link fails

The partial-failure branch returned before the cleanup, so a pick that had
already linked (and been consumed) stayed in state and would be re-linked
on the next transaction the reused dialog opened. Picks are safe to drop:
one that failed to link was never stamped, so it is still in the inkorg.

Also record in DECISIONS.md that no "saknar underlag" predicate keys on
transactions.document_id, which is what made leaving the pin off safe.

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

* fix(transactions): lock the inbox pick controls while booking

handleConfirm captures pickedInboxDocs when it starts, so a document
picked after the booking request went out was never linked, and the
cleanup on completion then removed it from the list with no error. The
pick and remove controls now follow the rest of the dialog and disable
while isProcessing.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:47:22 +02:00
Jakob Wennberg 0938646693 feat(onboarding): the branch question becomes its own journey step (#1615)
Founder feedback from a real signup: the done screen stacked the welcome,
the 8-row company profile card AND the branch question, pushing the
question below the fold, and the tiny favicons-in-ellipses provider chips
looked bad.

The done screen now ends in a revealed Fortsatt action; a new 'source'
step at the existing KLART station (same station grammar as momsyn/moms
under MOMSEN) shows only "Var fanns bokforingen innan?" with redesigned
provider tiles: a 2-column grid of generously sized choices, each with
the real logo on a small white bordered mark (the LogoMark grammar from
NewUserChecklist), SIE-fil and new-business as equal-weight text tiles,
flat hover, no lift. Everything fits one viewport.

Behavior preserved exactly: mode='first' only (reducer-guarded
DONE_CONTINUE), the single-choice latch, fire-and-forget keepalive PATCH
/api/onboarding/state, captureBranch analytics, branchDestination
routing, and the quiet skip escape.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:25:23 +02:00
Jakob Wennberg fb3feaea12 refactor(import): de-box the migration wizard and give it a real ending (#1617)
The provider-migration wizard was a pile of cards in cards: a boxed
progress rail with a fat bar, a bordered Card per step with more bordered
boxes nested inside, amber warning panels, and a result step made of a
verdict card, orange-badged per-year cards and a green-check card grid.
All of it against the design conventions (no boxes doing hierarchy's job,
attention is one ochre sentence, status colors are data not chrome).

- Progress rail: quiet uppercase step row (done steps muted with a check,
  current in ink) over a hairline thread whose ink segment is the progress.
- Every step de-boxed onto the page: serif step headlines, hairline-divided
  provider rows and option rows with the toggle on the right, per-year
  status as quiet muted lines, Verifikationsserie as one aligned row,
  connect/preview as headline + one quiet statline.
- Amber boxes become AttnLine sentences (SIE-required gate, failed years);
  errors keep strong color as text, never tinted panels.
- Step entry animates via the standard stagger-enter grammar (slideUp,
  reduced-motion safe); row lists stagger with data-no-stagger opt-out
  from the parent cascade.
- Confirm dialog: one sentence naming what happens, compact muted
  selection list, replace-years info as muted prose; the ochre caution is
  the only colored element.
- Result step rebuilt on the reveal grammar: settled TheaterCanvas beside
  a serif verdict derived from the real results, a compact statline,
  per-year outcomes as hairline lines with warnings as one ochre sentence
  each, Övriga data as a quiet two-column line list, next steps as quiet
  numbered lines. Document follow-up restyled to the same section grammar.
- Removed dead code (MONTH_NAMES, InfoItem) and the now-unused accordion.

No logic changes: handlers, fetch flows, OAuth/document reducers and all
step gating are untouched.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:17:26 +02:00
Jakob Wennberg b556475b01 fix(import): migration preview and theater read all fiscal years, not just the newest (#1614)
The /sie-data route parsed only the newest fiscal year's SIE file for the
import preview and the returned SIEData.parsed. Mid-year provider exports
have few or zero vouchers in the newest year, so the first real Fortnox
migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer"
and drew an almost-empty migration theater while the import itself
landed all 4153 vouchers from the older files.

- New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean
  whole-dataset merge (accounts union first-wins, vouchers concatenated,
  fiscal years union oldest-first re-indexed newest=0, balances and
  issues concatenated, dimensions deduped), with unit tests.
- /sie-data parses each file exactly once, builds the preview from the
  merged parse and returns parsed: merged; response shape unchanged.
  Validation stays newest-file-only so no previously accepted dataset
  is newly rejected.
- /preview drops latestOnly and computes sieStats from the merged parse:
  the connect step's "Hittade X konton och Y verifikationer" line
  renders from THESE stats, so this is where the founder-visible count
  was lying.
- The migration theater spreads its account waves across ~10s and
  births an additional wave on each real step label during the SIE
  phase (progress <= 55), through a shared rate-limited gate, so the
  canvas keeps performing over a multi-minute run. Narration labels and
  progress remain the wizard's real values; reduced motion unchanged.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:17:11 +02:00
Mattsson ea85f4c084 feat(providers): use WINT's real logo instead of the placeholder wordmark (#1619)
public/logos/wint.svg was an explicit placeholder ("swap for WINT's official
logo asset before launch"): an Arial "WINT" text node. Replace it with the
official mark, supplied as PNG with transparency.

Downscaled from 1402x1122 / 771 KB to 256x205 / 10 KB (trimmed, 16-colour
palette) so it sits in the same weight class as the other provider logos,
which are 1.6-4 KB and render in the same 40px chip.

Both references updated: the wizard's PROVIDER_LOGOS map and the /import
LogoChip row. No wint.svg references remain.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:04:43 +02:00
bjornbergenheim 5984652e47 fix(skatteverket): request the AGI kvittens scope and stop misdiagnosing the 403 (#1596)
* fix(skatteverket): request the AGI kvittens scope, not just the inlamning one

AGI is backed by two SKV APIs and each needs its own scope, but DEFAULT_SCOPES
only carried `agd`. That covers arbetsgivardeklaration/inlamning (POST underlag,
kontrollresultat, spara, skapaGranskningsunderlag), so a filing submits and
signs perfectly. The kvittens read lives on hanteraredovisningsperiod, which
requires `agdredovisningperiod`, so the flow died on its very last step with
403 {"error": "The required scopes are not authorized"}.

Confirmed against production: the same APIGW client id and secret reach the
bearer check on both APIs (401 "Token has been revoked" from each with a bogus
token), proving both subscriptions exist and the gateway contract was never the
problem, and SKV's application registration lists `agd`, `agdredovisningperiod`
and `momsdeklaration` as three separate scopes.

The body is identical to the APIGW subscription gap of #973, which is why
api-client's classification cannot distinguish the two from the string alone;
that split still needs the gateway-side check, so it is left as is.

Note the spelling `agdredovisningperiod`: no genitive s, exactly as SKV
registers it. Pinned with a scope-set regression test, since this is the third
time a scope has gone missing (#431 removed `ska` the same way) and the damage
is always invisible until a real filing fails.

The AGIPanel missing-scope banner now checks both scopes. It only looked for
`agd`, so the token shape that actually hurts, one that gets all the way
through signing before failing, produced no warning at all.

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

* fix(skatteverket): stop blaming the APIGW subscription for a missing token scope

`{"error": "The required scopes are not authorized"}` has two causes and the
gateway never says which: our APIGW client has no subscription for the API
(#973), or the token lacks the scope that API requires. The message named only
the first, so a real production filing spent its debugging time in
Utvecklarportalen while the actual fix was a scope the code never requested.

The message now names both knobs, and says WHICH service refused. That last
part is what was really missing: the sibling inlamning API kept working
throughout, so "Skatteverket denied the call" pointed at nothing. Ruling the
two apart still needs the out-of-band test (call the API with a deliberately
invalid bearer and the same Client_Id: a subscription gap fails at the gateway
with this body, a scope gap reaches the bearer check and answers 401), which is
now documented at the predicate instead of living in someone's memory.

Classification is deliberately unchanged. ACCESS_DENIED stays out of
RECONSENT_ERROR_CODES: the scope case does need a reconnect, but only after the
scope exists, so an automatic reconsent would rebuild the self-perpetuating
banner of #1155.

This reverses one specific decision from #1250, which removed the
SKATTEVERKET_SYSTEM_SCOPES mention on the reasoning that the gateway rather
than the scope list had refused. That reasoning assumed the body could tell
them apart. It cannot. The test asserting the omission is inverted, with the
reason recorded.

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

* fix(salary): make the AGI kvittens-scope notice dismissible

The two AGI scopes get different treatments in AGIPanel: a token
without agd keeps the hard reconnect nudge, but a token missing only
agdredovisningperiod gets its own softer, dismissible notice. Every
existing hosted token lacks the new scope, and until Skatteverket's
application registration carries it a reconnect mints the same grant
again (SKV silently drops unregistered scope names), so a hard
reconnect demand would be the #1010 un-clearable banner, with each
futile attempt costing a BankID round-trip. Dismissal persists per
granted scope string: a futile reconnect stays quiet, a widened grant
re-evaluates from scratch.

Also asserts the shared prenumeration/scope message and the refused
API path on the 401 contract test, matching the 403 test (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(salary): point the kvittens-scope notice at Skatteverket's own e-service

The compliance review flagged that dismissing the notice could leave a
filer without a software-side path to the receipt. Retrieval never
depended on the notice (the kvittens cron retries server-side and the
period's status row stays unconfirmed until a kvittens lands), but the
copy now also names the fallback that always works: verifying the
receipt in Skatteverket's Arbetsgivardeklaration e-service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(salary): use the ladder radius on the kvittens notice

The radius ladder guard landed on main after this notice was written;
rounded-md is dead vocabulary, bordered boxes are rounded-lg.

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

* fix(salary): scope the kvittens-notice dismissal to the employer

The localStorage key carried only the granted scope string, so on a
shared browser one company's dismissal hid the notice for every other
company with an identical grant. Skatteverket tokens are per company,
so the key now includes arbetsgivare alongside the scope string; the
same-grant reconnect behavior per company is unchanged.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-14 12:01:02 +02:00
Mattsson 315b94019b feat(providers): release the WINT card in the migration wizard (#1618)
WINT rendered as a disabled "Kommer snart" chip while the provider shipped
dark. The provider is wired end to end, so empty COMING_SOON_PROVIDERS and
let the card be selectable. WINT still needs WINT_MIGRATION_ENABLED=true in
the environment: that server-side /connect gate is unchanged and is what
actually authorizes the connection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:57:09 +02:00
Jakob Wennberg 2d97fbf1bc fix: false popup-blocked toast on Visa dokument + scope SKV reconnect line to skattekonto source (#1613)
* fix(documents): stop false popup-blocked toast on Visa dokument

window.open() returns null BY SPEC when 'noopener' is in the features
string, even when the tab opens, so the destructive 'Tillåt popupfönster'
toast fired on every successful open. Open without the features string and
sever the reverse channel manually (tab.opener = null), the same pattern
lib/browser/deferred-tab.ts already uses; the toast now fires only on a
genuine popup block.

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

* fix(transactions): scope the SKV reconnect line to the skattekonto source

The reconnect attn line rendered on /transactions whenever the SKV
connection needed renewal, regardless of what the user was looking at, so
it read as permanent noise. It now shows only when the source picker is on
Skatteverket (the rows it actually explains); the skattekonto page keeps
its own reconnect line.

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

* fix(transactions): keep Skatteverket source pickable while reconnect is needed

In the reconnect-needed state the transaktioner fetch 401s, skvRows goes
empty, the Skatteverket option left the source picker, and the stale-filter
effect reset the filter to 'all': the source-gated reconnect line became
unreachable exactly when it applied. Show the source whenever rows exist OR
reconnect is needed (skvNeedsReconnect already requires connected=true, so
never-connected companies get no phantom source).

No component test: repo test scope is lib/ + app/api/ (no component tests).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:04:36 +02:00
Jakob Wennberg 9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00
Jakob Wennberg 18c20e68e6 fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals

Skatteverket connections become per (user, company): the token table
carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half
migrations), so one connection leaked "connected" onto every company the
user belongs to, sync ran the token against the wrong orgnr (behorighet
403), and reconnecting from another company silently moved the row and
went dark on the first company's crons. Token reads/writes are now scoped
by company through the whole chain (token-store, api-client refresh
coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP),
/skattekonto/saldo answers 401 NOT_CONNECTED for companies without their
own row (which is what the page's startkort keys on), and the dashboard
connect-nudge counts only the active company's row.

Bookkeeping's pristine start card now keys on all-years emptiness via a
count probe instead of "no active filters": the default fiscal-year
selection counted as a filter, which made the card unreachable on
brand-new companies (it showed "inga traffar" instead).

Two browser-global localStorage keys become company-scoped with legacy
fallbacks: the inbox onboarding dismissal (dismissing on one company hid
the card everywhere) and the periodisering auto-detect toggle.

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

* fix(scoping): dedupe cron work per (user, company) + guard the ledger probe

CodeRabbit findings on #1610: the skattekonto sync cron still deduped
token rows by user_id alone, which would drop every company but one for
multi-company operators (the exact scenario the PR fixes); and the
all-years ledger probe could leave a stale false behind on a failed
refetch, letting the pristine card render unconfirmed. The probe now
resets to unknown in flight and carries the fetch generation guard.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:21:57 +02:00
Mattsson 4e14182a00 fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611)
* fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation)

A user's first lönekörning surfaced öre amounts in the AGI payable while
Skatteverket deals in whole kronor. Three connected defects:

- the AGI XML rounded amounts (Math.round); öretal bortfaller (SFF
  2011:1261 22 kap. 1 §) requires truncation, and FK487 must be
  Skatteverket's own per-sats computation on the whole-krona underlag sums
  (IK587, kontroll B_006), not a truncation of the öre-exact engine sum
- the salary booking credited 2731 with exact öre, leaving a residual
  after the whole-krona skattekonto draw; 2731 now carries the declared
  amount with the remainder on 3740 (Öres- och kronutjämning)
- the LB payment file and TaxPaymentPanel paid/showed öre; they now use
  the declared whole-krona totals stored on agi_declarations (which also
  lets skattekonto auto-settlement match the draw); legacy öre rows keep
  paying öre-exact so pre-deploy bookings still clear 2731

New lib/salary/declared-avgifter.ts implements the SKV computation (per-IU
whole-krona underlag, per-sats sums, youth/växa cap splits, exact integer
math) shared by the AGI generator, the booking split and the preview.
Review overrides route all legs through the same per-category truncation;
basis overrides are inert on money totals (they never reach the filed
IUs); the v1 book route gains override parity with book-run; F-skatt rows
ignore avgifter overrides on every surface. Booked runs show their posted
verifikat instead of a recomputed projection. tax_withheld_override
requires whole kronor. Adversarially verified over three /skeptic rounds.

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

* chore: merge origin/main and re-ratchet the öre-round baseline

The merge brought #1609 (net-pay öresavrundning) whose two new
Math.round(x*100)/100 occurrences are counted against the baseline this
branch had tightened from 637 to 629; 631 keeps the net -6 improvement
without policing already-merged code.

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

* fix(salary): address PR review (hybrid override computation, legacy youth cap, robustness)

CodeRabbit round on #1611, all findings in one pass:

- computeDeclaredAvgifterWithOverrides: one shared hybrid for the AGI
  generator AND the booking split. Overridden rows contribute their manual
  amounts per category; colleagues keep the SKV-exact per-sats underlag
  computation (a FoU override on one employee no longer costs the rest of
  the roster kronor of declared accuracy)
- youth cap keys on the RESOLVED category so legacy null-category rows
  classified as youth by the rate heuristic still get the 25k split
- F-skatt rows zero their avgifter_basis on both booking surfaces and in
  the preview, matching the AGI's isFSkattRow invariant
- preview route: posted-voucher lookup errors return 500 instead of
  masquerading as a booked run with no vouchers; 400/500 tests added
- run page clears stale AGI totals when the tax-payment fetch fails
- SalaryOverridePanel truncates the tax override to whole kronor so the
  schema's .int() cannot bounce a decimal input with a 400
- v1 book route override parity pinned by a lifecycle test
- DECISIONS.md format fixes + superseded entry marked; exempt category
  mapped explicitly; unified truncation-drift band with rationale

Declined (recorded): dating the decision entries 2026-08-13 (bot assumed
UTC; the decisions were made after midnight local time).

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

* fix(salary): round-2 review nits (shared F-skatt helper, test hygiene)

- isFSkattStatus in declared-avgifter.ts: single source for the F-skatt
  exclusion, consumed by book-run, the v1 book route, the preview route and
  the AGI generator, per the Swedish review's drift-risk finding
- declared-avgifter test suite gets the standard beforeEach cleanup

Declined (recorded for the summary): auto-generated correction voucher for
regenerated legacy periods (data-repair follow-up needing Emil's go); SFF
22 kap. 1 par. citation doubt (verified against lagen.nu and already shipped
in tax-tables.ts); 3740 scope doubt (BAS generic utjamning account, Visma
praxis, matches the user's reference voucher).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 02:22:07 +02:00
Mattsson fbe4e18730 feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link

gnubok_categorize_transaction only spoke a 19-category enum mapping to 21
hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were
unreachable from the agent surface even when active in the chart.

- add account_override to gnubok_categorize_transaction with v1 REST
  semantics via a shared helper (lib/bookkeeping/account-override.ts):
  business-side replacement, class-2 auto-VAT drop with the 2610-2649
  moms-line exception, plus a same-account degenerate guard; validated at
  staging and re-validated at commit
- align the gnubok_create_voucher staging gate with the engine's seeding
  semantics: BAS 2026 accounts merely absent from the chart pass (the
  engine backfills them at commit) and the preview lists
  will_activate_accounts with BAS-name fallback; non-BAS unknown and
  inactive accounts still rejected
- stop suggest_categories silently dropping mapping rules whose account
  is outside the fixed category maps; they surface with the rule's own
  account and an explanatory match_reason
- correct the create_account next-step hint (categorize could never use
  the new account before; now true via account_override)
- point the settings "Kontoplan (BAS)" link at /chart-of-accounts and
  redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in
  #850; the deep link never worked after the #854 merge collision)

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

* fix(mcp): address review findings on account_override

- commit executor rejects a present-but-malformed stored account_override
  loudly instead of degrading to the category default (CodeRabbit major;
  the approver approved a preview showing the override account); with
  commitPendingOperation regression tests
- accountToCategory returns null for unknown income accounts so custom
  income accounts get the same diagnostic as expenses (CodeRabbit minor),
  with income + reason-accumulation tests (CodeRabbit nit)
- pin the class-2 VAT-drop balance invariant with a test through
  buildTransactionEntryLines (Swedish compliance review: gross booking,
  never an unbalanced net + missing VAT leg)
- account_override description asks the agent to state the actual
  affärshändelse in notes when overriding (BFL 5 kap description concern)
- eventBus.clear() in the two new test suites (CodeRabbit minor)

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

* fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent

Round-2 Swedish compliance finding: the class-2 VAT drop did not cover
margin-scheme (VMB) accounts in class 3/4, which are the override's
flagship use case, so a forgotten vat_treatment attached the category
default standard_25 and booked an ingående-moms deduction on a
transaction where input VAT is not deductible (ML 2023:200).

applyAccountOverride now takes explicit VAT intent (vat_treatment or
vat_amount present) and books GROSS with no auto-VAT line without it:
forgetting the flag under-deducts (lawful), never over-deducts. Both
call sites (MCP staging preview, commit core) derive the flag the same
way; the tool description states the enforced behavior. Deliberate
divergence from v1 REST recorded in DECISIONS.md.

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

* chore: move stray decision-log entry to the root DECISIONS.md

The round-2 entry was appended from the wrong working directory and
landed as lib/bookkeeping/__tests__/DECISIONS.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 01:24:48 +02:00
Mattsson 4bb0655e4a feat(salary): öresavrundning of net pay to whole kronor (#1609)
* feat(salary): öresavrundning of net pay to whole kronor

Some banks reject salary payment files whose amounts carry öre. New
company_settings.salary_net_rounding toggle (off by default): the engine
rounds each net payout up to the next whole krona, never down, and emits
a derived oresavrundning line item (semesterersattning pattern) that
debits 3740 Öres- och kronutjämning so the salary entry stays balanced.
Gross, tax and avgifter are untouched, so AGI/KU are unaffected. Payment
files (pain.001 + Bankgirot LB) get whole-krona amounts via the rounded
net_salary. Toggle in salary settings; payslip and run detail show the
line item.

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

* fix(salary): keep employer cost on the shared definition; block manual rounding lines

Skeptic findings on the öresavrundning commit: (1) the engine included
netRounding in totalEmployerCost while payslip summary, KPI cards and
lönejournal recompute the figure from stored columns, printing two
different totals on the same payslip; employer cost now stays on the
shared definition and the öre cost is carried by the 3740 ledger line.
(2) 'oresavrundning' is excluded from the line-item create/update
schemas: it is the only item type the booking keeps out of the gross
reconciliation, so a manually created row would structurally unbalance
the salary verifikat.

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

* fix(salary): add the item_type CHECK as NOT VALID, validate separately

Compliance-swarm finding (SOC 2 CC8.1): the CHECK re-add scanned
salary_line_items under the ADD's ACCESS EXCLUSIVE lock. Split per the
house pattern (DECISIONS.md 2026-07-13): 20260813143000 re-adds the
constraint NOT VALID, new 20260813143001 validates it under SHARE UPDATE
EXCLUSIVE in its own transaction. The list is a strict superset of the
previous CHECK, so validation cannot fail. Both files are branch-only,
so editing in place is within the never-modify-shipped rule.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 00:36:33 +02:00
Mattsson 4a9fa5e6c5 feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

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

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

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

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

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

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

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

* docs(decisions): log preview-tracker orphan repair after migration rename

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:57:53 +02:00