Commit Graph

707 Commits

Author SHA1 Message Date
Jakob Wennberg bb5fafe87b fix(orders): book webshop orders against 1686 and stop the missing-account dead end (#1697)
Booking an order from the Orders page could fail outright on a fresh
company. seed_chart_of_accounts() seeds a deliberately small chart:
3001/3002/3003 and 2611/2621/2631 are in it, but 3004, 3740 and the
clearing account are not. All three are reachable from an entirely
ordinary order (a 0%-rate line, an ore residual, or simply no
payment-method mapping yet), and the engine treats a missing or
inactive account as AccountsNotInChartError, so the user's first click
on Bokfor returned an error naming accounts they had no reason to know
about, with no way forward but to hand-add them.

The book route now ensures the closed set of accounts our own prefill
can emit exists before drafting. Deliberately narrow: only accounts in
WEBSHOP_PREFILL_ACCOUNTS are ever created, and only when a submitted
line uses one, so an account the user typed still surfaces as a real
error instead of quietly growing the chart. A deactivated row is
reactivated rather than duplicated, and every failure is swallowed so
the engine's typed error still wins over a chart tidy-up.

The unmapped default also moves from 1680 to 1686. 1680 is the generic
"Andra kortfristiga fordringar" parent; 1686 "Fordringar for kontokort
och kuponger" is what BAS defines for a claim on a payment provider,
which is what money sitting at Klarna or Stripe actually is. The Stripe
extension already settles against 1686, so a store running both
surfaces now shares one clearing account instead of splitting the same
receivable across two.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:51:02 +02:00
Mattsson 3a1b842e4a feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
2026-08-19 12:04:24 +02:00
Mattsson 43cde6deb9 fix: unignore transactions during categorization (#1683)
Fixes #1660
2026-08-19 11:00:02 +02:00
Mattsson dfa7097f3a fix(auth): secure white-label invite and reset links (#1680) 2026-08-18 23:17:26 +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 72603abfd6 feat(invoices): show the ROT/RUT claim on the invoice detail page (#1690)
The claim was visible only on the PDF (audit item 8, reported by kalletoxic
2026-08-18): the detail page showed the unreduced total and nothing about
the deduction. Now:

- Totals: Totalt stands, a Skattereduktion ROT/RUT row follows and the bold
  line is Att betala, computed with the same getAmountToPay() as the PDF and
  the invoice email so the three never disagree.
- Each claimed line shows kind, arbetstyp, hours and its deduction amount
  under the description (desktop + mobile).
- New Skattereduktion card: customer share vs Skatteverket share, masked
  personnummer, fastighetsbeteckning/BRF/lägenhet for ROT, and where the
  begäran om utbetalning stands (reads rot_rut_payout_request_items; paid
  invoices without a begäran get the one-line CTA to /invoices?rot-rut=1).

Verified in the sandbox against a RUT invoice in draft, sent and paid state.

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:33:11 +02:00
Mattsson 7d56e0ec01 fix(mcp): use canonical VAT deadlines (#1679)
* fix(mcp): use canonical VAT deadlines

* fix(mcp): handle incomplete VAT settings

* fix(mcp): block unknown VAT deadlines

* fix(mcp): tighten VAT tool description

* fix(mcp): fail closed on missing entity type

* ci: retry timed-out preview

* fix(vat): scope annual filing method requirement
2026-08-18 17:22:52 +02:00
Jakob Wennberg 1af5846adf fix(skatteverket): let company members read data connected by another member (#1691)
Closes #1673. Token rows are per (user, company), but the read resolver
short-circuited to the caller's own row whenever a userId was passed, so a
member who never pressed "Anslut" resolved to "no token" for a company that
was connected. The company-wide fallback used .maybeSingle(), which errors
as soon as two members have both connected and turned that into "nobody
connected" for everyone.

- resolve-auth: findCompanyTokenUser() reads all of the company's rows
  ordered by created_at desc and picks the caller's own active row first,
  then any other member's active row, then needs_reconsent rows; the userId
  branch no longer short-circuits. The auth carries the token OWNER's userId
  so refresh writes back to the owner's row.
- /skattekonto/saldo and /skattekonto/sync resolve the company token instead
  of getTokens(caller); /declaration/submitted and /decided answer a
  SESSION_EXPIRED reconnect prompt for needs_reconsent instead of
  NOT_CONNECTED. Connect/disconnect//status stay on the caller's own row.
- The connection.expired event names the token owner, not the caller who
  triggered the sync; the notification lookup filters by company too.
- The two kvittens crons flag needs_reconsent through the same shared pick
  instead of .maybeSingle().

Tests: two members, one connects, both read; both connect, both read; no
row -> NOT_CONNECTED; dead-only rows -> reconnect prompt; sync auth carries
the owner; event recipient is the owner.

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:40:10 +02:00
Mattsson 619b446c52 fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction (#1685)
* fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction

The Swish payment QR on invoice PDFs encoded the pre-deduction invoice
total (getDisplayTotal), while the totals block and the invoice email
state "Att betala" as total minus the ROT/RUT deduction (getAmountToPay,
fakturamodellen). Since the Swish payload locks the amount (editmask 0),
a customer scanning a RUT/ROT invoice was asked to pay the full total
with no way to correct it: overpaying by the entire skattereduktion.

Swap the QR amount source to getAmountToPay(...).toPay so the QR, the
printed "Att betala" and the email always agree. A fully deducted
invoice (toPay = 0) now renders no QR via the existing amount > 0 guard.
All seven render surfaces (send, preview, pdf, v1 send/pdf, MCP commit,
recurring, issue-and-book) go through this one helper.

Reported by a user: "QR-koden for swish stammer INTE med beloppet man
ska betala. Den tar INTE hansyn till reduktionen."

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

* fix(invoices): select the amount-to-pay columns on the v1 pdf and send surfaces

Skeptic review of the Swish QR fix found it was a silent no-op on the v1
GET pdf route: its column projection predated ROT/RUT and omitted
deduction_total (and ore_rounding), so getAmountToPay saw undefined,
treated it as "no deduction", and the route kept emitting a locked
full-amount QR while the sent email said the deducted "Att betala".
INVOICE_FULL_COLUMNS (v1 send renders from it) likewise omitted
ore_rounding, ignoring the per-invoice oresavrundning override there.

Move INVOICE_PDF_COLUMNS into lib/api/v1/invoice-columns.ts, add
deduction_total, deduction_personnummer_last4 and ore_rounding to it, add
ore_rounding to INVOICE_FULL_COLUMNS, and pin the amount-path columns of
both projections with a test: a projection gap does not error, it renders
the wrong money on one surface only, so it must be caught structurally.

Also records the defect and remediation in DECISIONS.md per the
compliance-swarm change-risk finding (the repo has no risk_register.csv;
the decision log is its equivalent).

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

* fix(invoices): gate the Swish QR to payable documents and restore delivery_date on the v1 pdf

Swedish accounting review round 2: buildSwishQrDataUrl had no non-payable
gate, so a kreditfaktura (a refund document) still produced a locked
Swish payment QR at helper level; the template happens to hide the
payment box for credit notes, but a payment request against a refund
must stay impossible rather than merely unrendered. Apply the same
document gate buildPaymentLinkQrDataUrl already has (invoice documents
without credited_invoice_id only) and pin it with tests replacing the
credit-note parity case.

Also add delivery_date to INVOICE_PDF_COLUMNS: ML 17 kap 24 p.7 requires
leveransdatum on the invoice when it differs from the invoice date, the
template renders exactly that, and the v1 pdf projection silently
dropped it. Same projection-starvation class as the previous commit.

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

* docs(invoices): name the covered render surfaces and drop the contested lagrum point number

CodeRabbit round 3, both documentation-only: the DECISIONS defect record
said "all surfaces" while the editor preview is deferred to #1686, so it
now lists the covered surfaces explicitly; and the delivery_date comment
cited ML 17 kap 24 p.7 where CodeRabbit reads p.8 in SFS 2023:200 while
the repo's swedish-invoice-compliance reference table says p.7, so the
citation drops the point number and stays at the paragraph, which is
correct under either enumeration. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:39:49 +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
Mattsson bc357531cc feat(shopify): port the order sync from the transactions feed to webshop_orders (#1676)
* feat(shopify): port the order sync from the transactions feed to webshop_orders

Shopify orders now land as rich rows on the Orders page (platform
'shopify'), the same surface WooCommerce uses, instead of opaque
bank-feed rows on the 1584 cash account:

- order-sync.ts writes through the shared upsertWebshopOrders service;
  the 1584/ensureManualCashAccount wiring is gone (prod has zero Shopify
  feed rows). Cursor/overlap/dedup, revoked classification and the
  frozen external_id formats are unchanged.
- vat_breakdown is reconstructed from the order-level taxLines
  (net = tax/rate, remainder as a 0%-bucket, refuse on unusable data);
  refund VAT is prorated from the parent order's mix. The line-item
  snapshot is stored only when it reconstructs the charged total to the
  ore, else the invoice conversion falls back to one aggregate line.
- GraphQL query gains createdAt, taxesIncluded, taxLines, lineItems and
  shippingLines (all non-PII; page size 100 -> 25 for query cost).
- Nav gate counts active shopify_connections; the Orders empty-state CTA
  goes to the platform-neutral /import hub; panel/manifest copy now
  points at the Orders page (sv + en).
- Paid-only qualification and the 90-day backfill stay; the
  bookkeeping-lock row filter is dropped (lock is enforced at booking,
  parity with WooCommerce).

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

* fix(shopify): carry the prorated parent tax on refunds when per-rate bucketing is refused

A refund whose parent vat_breakdown was refused (unreported rates) stored
total_tax 0 and prefilled a 0%-refund with no moms reversal. The parent's
total tax is now prorated into the refund row, so the booking dialog's
ratio-inference fallback presents an editable bucket with the reversal
instead (CodeRabbit + Swedish review + skeptic finding). Adds the
mixed-rate line and truncated shipping-page tests CodeRabbit asked for.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:57:01 +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
Mattsson dc311726de feat(mileage): prefill distance and purpose from earlier trips on the same route (#1657)
* feat(mileage): prefill distance and purpose from earlier trips on the same route

Christoffer's beta feedback: recurring routes meant retyping the same km
every time. Fran/Till now autocomplete from earlier trips, and when the
pair matches a previous trip the one-way distance and purpose prefill
from the latest match. Only empty fields are filled, edit mode is
untouched, and a hint under the km field shows when a value came from
route memory.

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

* fix(mileage): invalidate route prefill when the route changes

Skeptic + review findings on the first push, resolved in one pass:

- Stale prefill: km/purpose filled from a matched route survived onto a
  different route (typing past the match, or switching Fran), with the
  hint still claiming same-route provenance. Prefill state now tracks
  the route key and the exact prefilled strings; when the key changes,
  fields still holding those strings are cleared and the match re-derives
  for the new route. User-typed values are never touched.
- Purpose was filled with no indicator: the hint now renders under both
  km and purpose, each cleared independently by manual edits.
- Prefill now uses the unrounded half of a stored round trip (21.25, as
  the copy flow does) so the round-trip toggle re-doubles to the exact
  stored km.
- Per-keystroke O(n log n) sort replaced with a WeakMap-cached sorted
  order per trips array.
- Docstrings on all route-memory exports.

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

* fix(mileage): offer a route's prefill at most once and drop stray NUL byte

Second skeptic pass on the rework found two issues, both fixed:

- Same-key refill rebuilt the prefill record from scratch, dropping the
  other field's live tracking and re-filling a field the user had
  deliberately emptied. applyRoutePrefill now matches only when no
  record exists for the current route key; the record survives as an
  offered-marker even fully disowned, so per-field tracking is stable
  and an emptied field stays empty until the route actually changes.
- routeKey embedded a raw 0x00 byte as separator, which made git treat
  the file as binary and killed diff review. The separator is now an
  explicit String.fromCharCode(10) newline, which normalizeLocation can
  never produce, keeping keys collision-free and the source printable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:16:52 +02:00
Jakob Wennberg a447b29210 fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice (#1655)
* fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice

remaining_amount is NOT NULL DEFAULT 0 and every payment surface (payment
dialog, bank match, Stripe sync, agent mark-paid) reads it as the customer's
open balance. Four writers omitted it, so their invoices looked settled: the
dialog rejected every payment as an overpayment and the bank match saw
nothing to clear. Prod carried 337 such open invoices on 2026-08-17
(backfilled the same day, snapshot in _backfill_remaining_20260817).

- Migration 20260817191708: BEFORE INSERT trigger invoices_derive_remaining_amount.
  When remaining_amount is NULL/0 on a real invoice (document_type invoice,
  not a credit note) with total > 0 and a status that still owes money, it
  becomes total - paid_amount - deduction_total (>= 0). The ROT/RUT share is a
  1513 receivable on Skatteverket, never the customer's, exactly as
  buildInvoiceWriteData computes it. INSERT only: settlement code owns
  updates and legitimately writes 0 when paid in full.
- pg-real test: derivation, explicit value respected, paid/prior/deduction
  arithmetic, drafts + overdue, paid/cancelled keep 0, credit notes and
  proformas untouched, never negative.
- Writers fixed as well: proforma -> invoice conversion (dashboard route and
  MCP commitConvertInvoice), MCP commitCreateInvoice, sandbox seed.

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

* fix(sandbox): every row in the seed invoice batch carries remaining_amount + paid_amount

PostgREST normalises a bulk insert to the union of keys, so a row that
omits a column the others set arrives as NULL, not as the default. Keep the
draft row on the same contract as the rest of the batch (CodeRabbit).

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 21:36:16 +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
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
bjornbergenheim 43a71aec3c fix(supabase): stop server clients leaking a 30s refresh ticker per request (#1612)
* fix(supabase): stop server clients leaking a 30s refresh ticker per request

`autoRefreshToken` defaults to true in supabase-js, and off-browser
@supabase/auth-js starts the refresh ticker unconditionally:

    // in non-browser environments the refresh token ticker runs always
    this.startAutoRefresh()

That is a setInterval firing every 30 s. It calls unref(), so the process
still exits, tests pass, and Vercel never notices because the process is
torn down long before the tickers accumulate. But unref() does not make a
timer collectable: it stays registered in the event loop and remains a GC
root for its callback, which closes over the GoTrueClient, the
SupabaseClient, and the whole request scope around it.

A long-running self-hosted instance therefore leaks one timer plus one
entire request graph (socket, IncomingMessage, ServerResponse, headers,
route context: ~100 kB) per client constructed. One died of "JavaScript
heap out of memory" after 42 h, the last 24 of them completely idle. The
heap snapshot showed 445 retained request graphs and ~1050 Timeouts in
the 30 000 ms bucket, retained via `autoRefreshTicker`, and the rate
matched the traffic exactly: the Docker healthcheck polls /api/health
every 30 s and the webhook dispatch cron runs every minute, so
3 clients/min x 148 min = 444.

- new lib/supabase/service-client.ts: createServiceRoleClient() applies
  SERVER_AUTH_OPTIONS, spread LAST so a caller passing its own auth block
  cannot re-enable the ticker
- 22 call sites migrated; only booking-templates/sync/cron had ever
  passed the options itself
- guard 9 in no-new-antipatterns.mjs fails CI on any new value import of
  supabase-js's createClient outside the wrapper; type-only imports are
  fine. Verified to fail on a deliberate regression and pass once fixed
- browser clients untouched: a signed-in tab genuinely needs the refresh,
  and lib/supabase/client.ts is built on createBrowserClient anyway

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

* fix(checks): catch namespace imports in the leaky-supabase-client guard

The guard only matched named imports, so

    import * as sb from '@supabase/supabase-js'
    sb.createClient(url, key)

reached createClient through member access without ever naming it, and
passed. Verified against the real script before and after: the shape is
flagged now, and `import type * as sb` still passes.

Namespace value imports are treated as leaky outright rather than tracking
member access, which keeps the check a regex over source text with no new
dependency.

Review also suggested excluding *.test.tsx alongside *.test.ts. Skipped: the
repo has no .test.tsx files, and all four sibling checks in this file use
`.test.ts`. Diverging in one of them would read as an accident; if such files
appear, all four should change together.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:30:17 +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 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 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
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
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
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 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
Jakob Wennberg 456366fa70 fix(transactions): move the bulk-match attn line below the inbox list (#1602)
The recon bridge line sat above the inbox rows, pushing the actual
work below a promo-shaped sentence. Founder feedback: the rows come
first. Same trigger (>=5 selectable bank rows), same action, now
rendered after the table just above the footer status line.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:38:19 +02:00
Mattsson ced6f1c65b fix(reconciliation): await the pre-existing matched/storno match-log writes (#1606)
Final Swedish-review finding (approved by Emil): the six fire-and-forget
logMatchEvent calls that predate this branch in the four match routes are
now awaited, matching the rest of the PR and the DECISIONS claim that
every audit write is awaited. logMatchEvent never throws; on serverless
an unawaited promise can be frozen when the response returns, silently
dropping the behandlingshistorik row (BFNAR 2013:2 kap 8).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:35:14 +02:00
Jakob Wennberg 57d6651cfc feat(empty-states): startkort on six pages with strata imagery (#1603)
Replace the true-empty states on Kundfakturor, Transaktioner, Underlag,
Loner, Bokforing and Skattekonto with StartCard: a self-contained dark
hero (image-derived ground baked into the strata render, white primary
CTA) that says what the page can do instead of what is missing. Primary
CTAs lead with the connect/setup action per page (bank via PSD2 deep
link, mailboxes, Skatteverket, migration import); filtered/search empty
states and viewer fallbacks keep the old compact states. Design signed
off in the Startkort prototype iterations 2026-08-13.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:27:24 +02:00
Mattsson 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

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

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

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

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

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

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

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00
Mattsson 07e89d9b52 feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation

* fix(invoices): harden Peppol compliance guards

* fix(api): narrow Peppol document loading

* test(pg): hash Peppol fixture payload

* fix(invoices): address Peppol review findings

* test(pg): isolate Peppol provider events

* test(pg): isolate Peppol submission fixtures
2026-08-13 19:44:32 +02:00
Mattsson 05380ddf54 feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

Correcting or reversing an entry that already sits 3+ links deep in a
rattelse chain (correction_of_id/reverses_id walked in the DB, never
description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the
caller to book ONE correction expressing the chain's net effect. Agents
looped storno+rattelse 10 deep on a live company (63/193 vouchers noise).

The guard is advisory, never a dead end: allow_deep_chain bypasses it on
every surface (correctEntry/reverseEntry option, REST body, MCP tool arg
staged through pending_operations, and confirm dialogs with Ratta anda /
Aterfor anda in the web UI). MCP staging pre-flight fires the guard at
stage time so the agent reconsiders in the same turn, and the executor
re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for
the two bypass properties (trimmed to one sentence first).

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

* feat(agent): retry the Bedrock stream once on transient failures

A transient stream death (429/5xx, transport cut, or the two known
stream-corruption signatures: 'Unexpected event order' and 'request ended
without sending any chunks') killed the whole chat turn, stranding the
user mid-answer. The turn now retries once per turn after a short backoff:
safe because nothing is persisted until finalMessage() succeeds. A new
stream_restart event carries the pre-attempt text snapshot so the chat
client resets the partial bubble, drops uncompleted tool chips, and shows
'Forsoker igen...' until the retried stream produces text. Non-transient
errors (403, 400) keep the existing immediate-error path.

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

* fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1

apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain,
making references/journal-entries.md stale. Regenerated (hand-applied: the
generator output is deterministic from the registry). While wiring: the v1
correct route validated allow_deep_chain but dropped it, and the v1 reverse
route's strict body schema would have rejected it outright, leaving API
clients no bypass when the chain-depth guard fires. Both now forward the
flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall.

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

* chore: re-trigger CI after Vercel infra hang

The preview for e527e4044 compiled in 91s then hung 40 minutes in the
TypeScript phase and was killed with no error output; a CLI redeploy of
the identical code went Ready in 5m. Empty commit to refresh the git-
triggered deployment status.

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

* fix(bookkeeping): address CodeRabbit review on the chain-depth guard

- correction-chain: report rootVoucher only when the walk reached a
  genuine parentless root; a broken link, cycle, or hop-cap now yields
  null instead of presenting an intermediate voucher as the chain root.
- recordate: propagate allow_deep_chain end-to-end (recordateEntry
  option, route schema, and a Flytta anda bypass confirm in the dialog);
  a date move is another storno+rattelse layer and carried the guard
  with no override path.
- v1 correct/reverse: run the chain-depth guard before the dry-run
  return so a dry run gives the same verdict as the real execution.
- dashboard reverse route: 400 on malformed JSON or a non-boolean
  allow_deep_chain instead of silently reversing without the override;
  empty body stays the supported no-body case. Tests added.
- AgentChat stream_restart: discard the dead attempt's reasoning and
  re-arm the post-tool paragraph break so a retried turn doesn't render
  thinking twice or glue its continuation onto restored text.
- v1 reverse route doc comment updated for allow_deep_chain.

Not changed: the journal-list reverse flow (flagged as a dead end) can
never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only
for entries that are neither storno nor correction, and such entries
have no backward chain links, so their depth is always 0.

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

* test(bookkeeping): recordate route test expects the new options arg

recordateEntry now takes { allowDeepChain } as a sixth argument; the
route test's called-with assertion predates it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:32:41 +02:00
Mattsson d02fd82191 feat(vat): add per-account declaration treatments (#1588)
Closes #1457
2026-08-13 17:03:35 +02:00
Jakob Wennberg 7881f757a9 feat(bookkeeping): show who committed a verifikat, and mark agent work in Granskning history (#1591)
Flows build plan prereq 3 (provenance display). Pure UI over columns that
have existed since migration 20260619120000:

- types: JournalEntry gains committed_actor_type/committed_actor_label
  (the detail/chain APIs already select('*'), the type just lacked them)
- voucher detail: new "Bokford av" row in the Details card, derived from
  actor type + credential label, with the Bot mark for non-user actors
- Granskning Historik rows get the same actor circle pending rows have
  (Bot vs ClipboardCheck) so agent-originated history reads at a glance
- run-turn: correct the staged_operation params comment (tool-use input
  is a superset of pending_operations.params, not the same values)

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:01:15 +02:00
Jakob Wennberg ce6efdb3dc refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)
* refactor(pending): one pending-op-owned preview for chat, /pending and flow views

A staged pending_operation was rendered three separate ways: the /pending
page's OperationPreview switch (8 specialized renderers keyed on
operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers
keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack
that mapped stored operation_types onto 'gnubok_'-prefixed tool names on
hydration. This is the weakest seam ahead of flow-run views (plan seam
8.3): every new operation type had to be taught to render in two places
and silently degraded in the third.

Now there is one owner:

- components/pending-operations/OperationPreview.tsx: the /pending
  renderers moved verbatim, dispatched on operation_type, consumed by
  /pending, ApprovalCard and future flow-run views.
- components/pending-operations/vocabulary.ts: operation labels,
  single-action warnings and the one canonical rejection-category list
  (ApprovalCard's copy was byte-identical and is deleted).
- lib/pending-operations/tool-name.ts: the single translation point
  between bare operation_types and 'gnubok_' tool names, with tests.

toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only
recognized 4 tool names, so a hydrated card for any other operation type
(attach_document_to_transaction, match_transaction_invoice, ...) silently
fell back to a raw generic preview. Hydration now passes the stored
operation_type straight through attachStagedOperations to the card, and
live streamed cards derive it from the event's tool name, so every
operation type keeps its specialized preview on resume.

Per-surface chrome (list row on /pending vs inline chat card) is
deliberately kept: only the preview + vocabulary were the duplicated seam.

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

* chore: drop a stray hunt_title copy rename that rode along

'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was
uncommitted working-tree state from another session, swept into the
extraction commit by git add breadth. It is a product-naming call with
no en.json counterpart and does not belong in this refactor; preserved
in this branch's first commit if it turns out to be wanted.

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

* fix(pending): carry params to chat previews; guard preview amounts

CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders
its DocumentViewButton from params.document_id, which neither chat path
carried: the staged_operation stream event now includes the tool-use
input (the same values the staging tool stored as
pending_operations.params) and hydration selects the params column, so
an attach-document card in chat shows its evidence button live and on
resume. (2) InvoicePreview and CreateTransactionPreview cast amounts
straight into formatCurrency; a payload without one rendered 'NaN kr'.
They now share the same show-the-gap guard the legacy summary already
had.

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-13 16:17:15 +02:00
Mattsson 22eaab82e5 feat(invoices): add Peppol XML export foundation (#1585) 2026-08-13 16:17:00 +02:00
Jakob Wennberg 7ae3477b36 fix(mcp): page list tools past PostgREST's silent 1000-row cap (#1572)
gnubok_list_accounts returned exactly 1000 rows for a full BAS 2026 chart
(1290 accounts) with no truncation signal: PostgREST caps un-ranged selects
at 1000. Wrap the query in fetchAllRows, paging on the unique account_number
and re-sorting by sort_order in JS so the visible order is unchanged. Same
fix for gnubok_list_customers, gnubok_list_suppliers and gnubok_list_articles
(paged on id, re-sorted by name), the Accounted://chart-of-accounts resource,
and the REST v1 accounts.list route. All output schemas and registry metadata
unchanged.

Also note on gnubok_audit_package download_url that the signed URL points at
the Supabase storage host, so restricted-egress proxies may 403; offset the
added prose by trimming the same tool's own descriptions to keep the
tools/list payload under the 59K ceiling.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:30:12 +02:00