Commit Graph

519 Commits

Author SHA1 Message Date
Jakob Wennberg fea5dfd1f9 fix(payments): correct pain.001 dialect per Swedbank Validex run (#1507)
* fix(payments): correct pain.001 dialect per Swedbank Validex run

Real MIG validation (eken.validex.net) rejected the first generated file
on four rules: character set (e-acute in names), missing InitgPty OrgId,
BGNR creditors demanding a BGNR debtor, and Strd lacking RfrdDocAmt.
Names and messages now transliterate to the MIG set, the org number is
required at batch creation (settings first, companies fallback), bankgiro
payees debit the company bankgiro in their own PmtInf group when one
exists (IBAN otherwise, with Cdtr PstlAdr/Ctry SE always present), and
structured OCR remittance repeats the amount as RfrdDocAmt.

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

* fix(payments): review quick wins on the MIG pass

NFC-normalize before transliteration (decomposed marks from PDF-pasted
names fold to the precomposed forms the map knows), a dedicated settings
link label for the missing-org state, and coverage for an invalid
company bankgiro being dropped from the debtor snapshot.

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-10 20:53:43 +02:00
Jakob Wennberg f0bedc14af feat(payments): betalfil UI (betalfil 3/3) (#1505)
* feat(payments): betalfil UI (betalfil 3/3)

Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list,
preview dialog with per-line editable amount/date and exclusion reasons,
payment-files history page with re-download, cancel and a sequential
bulk mark-paid (duplicate guard respected, never forced), I betalfil
chip on rows in active batches, clearing/kontonummer fields on the
supplier form, and the supplier_payment_files namespace in sv+en.

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

* fix(sandbox): make the demo AP data betalfil-ready

Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo
invoice had remaining_amount 0 (no trigger derives it, so the list said
0 kr kvar att betala), and the company had no IBAN/BIC, all of which
excluded the seeded data from the betalfil flow. Numbers swapped for
Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added,
and both bulk-insert rows set remaining_amount explicitly per the
PostgREST normalization rule already documented inline.

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-10 20:07:04 +02:00
Jakob Wennberg f0ce322a7c feat(receipt-hunt): keep looking until the mailboxes stop yielding (#1499)
A press fetched at most three receipts, because fetching one means
downloading it and having a model read the PDF, and a backlog of forty
cannot finish inside one serverless invocation. Working through a real
backlog meant pressing the button a dozen times, which is the shape of a
stopgap rather than a feature.

The loop now lives in the page: it keeps asking for another bounded pass
until one comes back with nothing new, which is the honest signal that the
mailboxes hold nothing more for the purchases still open. Progress shows
as it goes and there is a Stop, read inside the loop so it takes effect on
the current pass rather than after every remaining one.

Deliberately not a queue drained by cron. The finest schedule this app
runs is hourly, so a queue would mean pressing a button and waiting an
hour for anything to happen: worse than what it replaces, and a new table
and worker to maintain. The cost of putting the loop in the browser is
that it runs while the tab is open, which is acceptable for work somebody
just asked for.

A cap of 25 passes is a backstop, not a budget: it exists so a pass that
keeps reporting work it never completes cannot run forever.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:15:40 +02:00
Jakob Wennberg 1d635b0d25 feat(receipt-hunt): look for receipts on request, from the mailbox settings page (#1496)
* feat(receipt-hunt): a button that looks for receipts on request

The nightly cron exists but still does not search mailboxes, and for a
good reason: a sweep of one real 172-message mailbox took over 600s,
against a scheduled function's 300. Pressing a button is the honest shape
for work that big. A bounded pass reports what it found and how much is
left, and the person decides whether to press again; a nightly run could
only truncate silently.

POST /api/receipt-hunt/run searches the mailboxes for eight purchases and
fetches at most ten receipts per press. Gated on the AI tier, because
reading the amount out of a PDF is what makes a fetched attachment
matchable at all: without it the hunt would file documents that can never
pair, which is worse than not running. Writes no journal entries; every
pairing is still a proposal waiting for approval.

The button lives on the mailbox settings page, which already ships, and
says what happened in words rather than a spinner that stops: "3 underlag
hämtade. 12 köp kvar att söka igenom."

huntCompany gains maxReceipts so a manual pass can carry a different
budget from a nightly one.

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

* fix(receipt-hunt): stop a manual press spending its budget on the wrong purchases

The first real press searched eight purchases, read forty mails and found
nothing, which looked like a broken model. It was the ordering.

Purchases are searched largest first, which is right for a nightly sweep
that eventually covers everything and wrong for a button pressed a few
times. On a real ledger the largest rows are the least likely to have a
findable receipt: rent already invoiced, bare payment references, direct
debits. Those filled the forty-mail cap, so the productive purchases
further down the list, the ones whose receipts are actually sitting in the
mailbox, were never read at all.

The cap was the binding constraint, not the time: eight purchases and
forty mails took 43s of the 300 available. A press now searches 25
purchases and reads 100 mails, measured at 85s and finding 7 underlag on
the same ledger that returned 0 before.

huntCompany gains maxMails alongside maxReceipts, so a manual pass can
carry a different budget from a nightly one rather than sharing an
environment default with it.

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

* fix(receipt-hunt): one underlag per purchase, and a press that fits its budget

Your second press exposed three things, none of which a dry run reaches.

It took 5.8 minutes. The 85s I measured was a dry run, which never
fetches, uploads or extracts; each fetched receipt costs about another
37s because it is downloaded, stored, and then read by a model that opens
the PDF. Seven of them ran past the 300s a serverless function gets, so
in production that press would have been killed. Four receipts per press
keeps a full pass inside the budget.

It fetched seven receipts and proposed nothing. A single mail carries the
invoice AND the receipt for one purchase under different names
("Invoice-E19DBF63-0021.pdf" beside "Receipt-2066-0204-8388.pdf"), and
the same receipt reaches a second mailbox on a different message. Each
was fetched separately, so the pool filled with identical candidates and
the matcher refused to propose any of them rather than flip a coin. The
per-run key is now the vendor and the total, which is what identifies a
purchase; the filename only decides when no amount was read. Nine
duplicates already in the pool were removed.

And with the duplicates gone it still proposed nothing, for a separate
reason: "Utlägg Norwegian" scored 0.18 against "Norwegian Air Shuttle
AOC AS". Utlägg is Swedish for an expense reimbursement, bank vocabulary
rather than a company, and leaving it in broke the token-subset match, so
an exact 1 998 kr pair leaned entirely on a date eight days out and fell
under the floor. Stripped, along with överföring, via internet, bg-bet
and autogiro, in the comparison path only.

normalizeMerchantName is untouched: it is the persisted konteringskarta
key with a SQL mirror, and its 22 string pins and the 27-pair golden set
still pass.

Measured after: the Norwegian pair proposes at 0.72.

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

* perf(mail): read each message once per press, not once per query

The third press proposed a pairing, which the second had not, but still
ran 5.2 minutes against a function's 300s. Cutting receipts from seven to
four had only saved 36s, which said the receipts were never the cost.

Every search fetched a full message for every hit, and a press searches
many purchases across every connected mailbox. One receipt mail answers
several of those queries, so 25 purchases against 2 mailboxes could ask
Gmail for well over a thousand messages to end up with a hundred distinct
ones. Deduplication happened in the caller, too late to save the work.

A mail's content never changes, so it is now read once per mailbox and
kept, bounded at a thousand entries and evicting oldest first. Measured
on the same ledger: 55 purchases and 100 mails now take 102s, where 25
purchases alone previously cost around 264s before a single receipt was
fetched.

clearMessageCache exists because tests reuse message ids and production
does not.

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

* fix(receipt-hunt): do not refetch a receipt the company already holds

The fourth press fetched four documents the company already had:
Bolagsverket, Supabase twice, Uber. They came back because I had deleted
them as duplicates, and the cross-run check is the message and attachment
id, which lives in the rows I removed.

That was my mistake, but it exposed a real gap. The vendor-and-total key
only deduplicates inside a single pass. Across passes the same purchase
still arrives as an invoice in one mail and a receipt in another, with
different file keys, and both were fetched: the pool fills with identical
candidates and the matcher then refuses to choose between them, which is
how a press can fetch four documents and propose nothing.

The pass now starts from what the company already holds, so its budget
goes on documents that are actually missing.

Receipts per press drops to three. Measured on this ledger, a fetched
receipt costs about 50s from download to a stored amount, and that is the
model reading the PDF rather than the network: seven took 5.8 minutes and
four took 5.1, both past the 300s a function gets. Three fits, but it is a
stopgap. Doing the fetch inside the request is the wrong shape for work
this slow, and the fix is to move it off the request rather than keep
shaving this number.

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

* fix(mail): a mail body must not outlive the run that read it

Two findings from the review, both correct.

The message cache held whole MailCandidate values, and one of those fields
is the mail body. The contract says a body is read once to extract fields
and discarded, and a process-global cache quietly broke that: bodies of
one company's mail sat in memory across requests until eviction or a
restart. The MailSearchService contract now has releaseCache, the Gmail
adapter clears its messages, and the hunt calls it in a finally so a
failed run releases them too.

The duplicate key accepted an empty vendor, so two unrelated documents
that happened to cost the same collapsed into one candidate. Those now
fall back to the file they came from: without a vendor there is nothing
to anchor an amount to.

The same finding caught something worse that I had introduced one commit
earlier. The persistent check derived its key from the stored extraction
while the fetch derived one from the reading model, so a document filed
as "Norwegian Air Shuttle AOC AS" did not recognise an incoming
"Norwegian" and was fetched again. Rather than guess at aliases, which
would fold "Google Cloud" into "Google Workspace", the identity is now
written onto the row when the receipt is filed and read back verbatim.
Rows filed before that fall back to the extraction.

receiptIdentity is one exported helper with its own tests, used by both
sides, instead of the same expression written twice.

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

* fix(receipt-hunt): a monthly subscription is not a duplicate of last month

The review caught that my duplicate key was worse than the problem it
solved. Anthropic bills the same amount every month, and keying on vendor
and total alone made July look like a duplicate of June: every later
receipt from any recurring supplier would have been suppressed forever,
silently. Duplicates block one proposal; that would have lost a receipt
per month per subscription.

The identity now carries the document date. Two documents for one
purchase share a date; June and July do not.

Two smaller faults in the same key. The amount was serialised as a raw
float, so 0.1 + 0.2 read as a different total from 0.3; it is rounded to
öre like every other money comparison in this codebase. And a document
with no vendor was identified by its filename alone, which collapses two
unrelated papers whenever a billing system attaches "invoice.pdf": those
now carry the message they came from.

The key is versioned so a future change to its shape cannot be mistaken
for a match against rows written under the old one.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:08:06 +02:00
Jakob Wennberg 38f5d9812e feat(receipt-hunt): find receipts in connected mailboxes and pair them on the amount (#1492)
* feat(receipt-hunt): nightly matcher pairing unbooked purchases with held receipts

Stages an attach_document_to_transaction proposal for every unbooked card
purchase whose receipt the company already holds, so the underlag is attached
before the transaction is booked and the gap never forms. When the user later
books it, categorize-core.ts propagates the document onto the new verifikat
through the matched_transaction_id link the executor writes.

Deliberately scoped to UNBOOKED transactions. The posted-verifikat backlog is
96% imported history whose originals live in the previous system, so it stays a
pull (the verifikat_missing_document worklist) rather than a nightly push.

Ranking reuses scoreUnderlagCandidates; the pool is loaded once per company
instead of per transaction, which removes both the N+1 and the newest-50
truncation a per-transaction lookup imposes on a deep backlog.

Five guards, each mutation-tested: a confidence floor above the shared
candidate floor, an ambiguity margin so two equally-good receipts are left to
the picker rather than coin-flipped, one-receipt-one-purchase, one live
proposal per purchase, and permanent suppression of pairs a human rejected.
Suppression is derived from pending_operations history rather than a new table:
terminal rows are immutable and a rejection is already the durable "no".

Runs 05:30 UTC, after the 05:00 bank sync. Gated on RECEIPT_HUNT_COMPANY_IDS,
which hunts nobody when unset so enabling it stays a deliberate act. No
migration, no journal writes, no UI: proposals land in the existing Granskning
queue.

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

* feat(receipt-hunt): dry-run mode for provkörning against a real ledger

Returns the pairings a run would stage without writing any of them, so a
company can see tonight's proposals before they reach the granskningskö and so
the matcher can be validated against production data without staging an
operation.

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

* fix(matching): fold Swedish bank descriptors so receipts reach their purchases

calculateMerchantSimilarity compared raw bank descriptors, so a receipt from
"Alviks kött och fisk" scored 0.125 against the bank's own row for it,
"Alviks koett och fisk K3667 Kortköp/uttag" — an öre-exact pair no threshold
could reach. Adds normalizeForMatch, used for similarity only, which folds what
the card rails add and never changes identity: the K#### token, Kortköp/uttag
verbs, a leading "Kortköp YYMMDD", trailing /YY-MM-DD dates, reference numbers
glued to the name, domain wrappers, legal forms, and the three ways banks mangle
Swedish letters (ö, transliterated "oe", and ?? mojibake). Processor markers
become spaces because the merchant sits before the star in GOOGLE*PLAY and after
it in K*IKEA GALLE. Token-subset containment is scored level with substring
containment so a receipt's legal name matches the bank's trading name.

normalizeMerchantName is left byte-identical and now documents why: it is a
transitive input to categorization_templates.counterparty_name, a persisted
UNIQUE key with a hand-written SQL mirror the ledger-context RPC recomputes at
query time. Changing it would make stored keys stop equalling computed ones, so
the konteringskarta join misses and insertOrUpdateTemplate inserts a second row
per merchant instead of migrating the occurrence counts.

Aggressive folding is safe because it is applied to both sides of every
comparison, so an over-eager fold still matches; the risk is collision between
different merchants, which the new tests guard.

Measured on 27 receipt/transaction pairs humans actually confirmed in
production: recall 27/27, and 0/7 false positives on deliberately similar but
distinct merchants. Full unit suite unchanged (13,004 passing), including the 22
string pins on the frozen key path.

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

* feat(mail): read-only Gmail connector so receipts are found without forwarding

Forwarding was the only way a receipt reached Accounted, and it is both
unpopular (97% of companies with the problem have never used their inbox
address) and fragile: Arcim's own forward has been off for weeks and nobody
noticed. This lets the hunt look in the mailbox instead.

Scope is gmail.readonly and nothing else. It can search and download attachment
bytes, and it structurally cannot send, modify or delete: the promise the
consent screen makes is enforced by the grant, not by our code being careful.
The consequence is deliberate: the agent can prepare a forward for a portal-link
receipt but can never send one itself.

Query-then-classify, never sync. For each unexplained purchase we run a
provider-side search in a -3/+10 day window, pull metadata for a handful of
hits, and keep nothing. No mailbox is mirrored and no message body is stored,
which is what keeps this inside Google's Limited Use terms and GDPR data
minimisation. Mail is searched only for purchases Underlag could not already
explain, so a receipt we already hold never costs a mailbox read.

The query ORs merchant against amount rather than requiring both: demanding both
misses every rebrand and reseller (Anthropic bills as Claude), while the amount
alone is a strong filter inside two weeks.

mail_connections is service-role only with RLS enabled and zero policies,
because the row holds a live refresh token and RLS cannot hide a column.
Uniqueness is (company, provider, address) so a second mailbox is additive and a
reconnect updates in place. Tokens are AES-256-GCM under their own key by
preference, since a mail grant reads correspondence rather than backups.

Core reaches the extension through a registered service, mirroring
lib/email/service.ts, so lib/receipt-hunt never imports from @/extensions and a
zero-extension build still compiles.

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

* feat(mail): connect UI and ingest, making the hunt reach into the mailbox

Two halves that together make the connector usable.

Ingest (lib/receipt-hunt/ingest.ts, core): fetches the attachment, files it as a
document and an inbox item with source 'mail_hunt', then stages the pairing.
It lives in core because it writes documents and inbox items, and an extension
may never import another extension; the mail extension only ever hands over
bytes.

No re-matching for a hunted receipt: it was fetched WHILE SEARCHING for a
specific purchase, so the pairing is known by construction. The search is a
deliberately broad OR query, which is exactly why the proposal still goes to a
human with the mailbox, sender and subject written on it rather than being
linked automatically.

Provenance goes in channel_context, never extracted_data, because retrying
extraction overwrites extracted_data wholesale and the record of which mailbox
a receipt came from has to survive that. A partial unique index on
(company_id, channel_context->>'mail_message_id') makes re-runs and the same
receipt arriving in two mailboxes idempotent, and a 23505 is treated as success
rather than an error.

Guards, both mutation-tested: a duplicate message costs no provider call, and an
oversized attachment is skipped rather than stored. One unreadable attachment
falls through to the next and never aborts a night's hunt.

UI: /settings/mail lists connected mailboxes with their health, connects a new
one through a user-gesture tab (opened before the await, so popup blockers do
not eat it), and disconnects behind a ConfirmDialog that states the outcome up
front, including that already-approved receipts stay because they belong to the
bookkeeping now. Strings in sv and en; the read-only promise is spelled out on
the page rather than buried in a consent screen.

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

* fix(mail): renumber migrations to clear a version collision on main

20260806150000 was already taken by preserve_preset_committed_at, and
woocommerce_connections plus enforce_balance_on_posted_insert landed after this
branch was cut. Two files sharing a version breaks every fresh database, which
only shows up on a clean setup rather than on an already-migrated one.

Applied to prod under the new versions (20260807090000 / 20260807090100), so
schema_migrations matches these filenames exactly.

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

* fix(receipt-hunt): make the mailbox search actually able to find an underlag

A provkörning against a real ledger returned the same seven unrelated
messages for every purchase, all reporting no attachments. Three separate
causes, each fixed and pinned:

1. `getMessageSummary` asked Gmail for `format=metadata`, which returns
   headers and omits `payload.parts` entirely. Every message therefore
   looked attachment-free, `bodyIsReceipt` was always true, and the
   `found.find(c => c.attachmentIds.length > 0)` guard in the hunt could
   never select anything: the feature could not file a single receipt.
   Gmail has no format that returns MIME structure without the body, so
   the body now comes down the wire; it is read for nothing and stored
   nowhere.

2. The bank's description is not a merchant name. "Lön Juli Jakob
   Överföring via internet" searched for "Juli" and matched most of the
   mailbox. Month names and payment-rail boilerplate are now stopwords.

3. Salary and tax runs are a company's largest outgoing rows, so they
   consumed the whole search budget hunting receipts that cannot exist.
   `canHaveEmailReceipt` skips them for the mail leg only. Deliberately
   narrow: a supplier invoice paid over bankgiro does arrive by mail, and
   an "Utlägg" reimbursement has a real receipt behind it.

Measured on the same ledger: 22 hits, 0 with attachments, 0 ingestable
-> 4 hits, all with attachments, 3 of 4 correct (Elgiganten, Sting,
Anthropic). The fourth matched a Stockholm billing address, which is why
every proposal still waits for a human.

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

* feat(receipt-hunt): let a model resolve merchants and pick the receipt

The keyword hunt was failing for reasons regex tuning cannot reach, all
measured against a real mailbox rather than assumed:

- `from:anthropic.com` returns 0. Receipts arrive here by being
  forwarded, so the sender is the user, not the vendor.
- The exact charged amount returns 0. The bank posts a converted SEK
  figure that appears nowhere in a USD receipt.
- A date window around the purchase returns 0, while the same merchant
  search without one returns 10+. A forward is stamped when it was
  forwarded, sometimes months later.

So the query now searches merchant names across the whole mailbox, and
precision is restored by judgement rather than by syntax. Two model calls
per run, both through forced tool use so the reply is a shape and not
prose to be parsed:

1. `planMerchantGroups` resolves bank descriptors to merchants and merges
   repeats. Six Anthropic subscriptions become one search and one
   decision instead of six of each.
2. `assignReceipts` decides which mail, and which attachment on it, is
   the receipt for which charge, and says why in a sentence the reviewer
   reads.

The attachment, not the message, is the unit of an underlag: a single
forward routinely carries receipts for several purchases ("Fwd: Kvitton
februari" has five). Migration 20260807103000 moves the dedupe key from
message to message+attachment, with a backfill, because the old index
would have silently blocked every receipt after the first in a forward.

The model may not produce any number that reaches the ledger. It returns
ids, a confidence and a reason; amounts, dates and the write stay in
deterministic code. Its answer is validated, not trusted: an unknown
message id, an invented filename or a low confidence drops the pairing,
and any failed call proposes nothing at all. Every result still waits
for a human.

Measured on the same ledger: 0 receipts that could ever be filed -> 3
correct pairings (Elgiganten, Sting office invoice, Anthropic), each
with a stated reason. The five remaining Anthropic charges are dated
after 2026-06-15, when forwarding to the connected mailbox stopped; the
model declined them correctly.

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

* refactor(receipt-hunt): amount first, and drop the confidence scoring

Three findings from how others build this, applied.

Production email search (Superhuman, Haystack 2026) reports that recall
comes from loosening retrieval and letting the model filter downstream,
not from tightening the query. Retrieval depth per merchant 12 -> 25, and
purchases the planner cannot name a merchant for are now searched by
amount alone instead of skipped: a line like "1260525758758
Europabetalning" identifies no merchant but is a real supplier payment
whose invoice may carry exactly that total.

Reconciliation engines weight amount far above date (Midday: 35% vs 5%)
because banks post late while amounts do not drift. The Gmail query now
leads with the amount and ORs the merchant, rather than dropping the
amount whenever a merchant alias exists. Still an OR: a receipt billed in
USD never contains the SEK figure the bank charged.

The confidence score is gone entirely. Research on verbalised confidence
finds it badly calibrated, clustered on round-number anchors and barely
better than chance at separating a model's own right answers from its
wrong ones. That matched what this ran into: the model anchored on 0.6 /
0.7 / 0.75 / 0.9, and the 0.7 threshold discarded two correct pairings.
It is replaced by an observation rather than a self-assessment, whether
the charged amount is actually visible in the mail, which is what a
reviewer checks first and what sorts the queue.

Also fixes a real defect the run exposed: the one-file-one-purchase guard
only held within a merchant group, so when the planner split one landlord
into "Sting" and "Kontorsplatser" both 15 000 kr charges were assigned the
same invoice. A file is now claimed once per run, which is the duplicate
underlag BFL forbids.

Measured on the same ledger: 3 -> 5 pairings, no duplicate.

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

* refactor(receipt-hunt): harvest receipts, then pair them on the amount

Splits the mailbox leg in two along the line of what each side can
actually know.

The model was being asked which purchase a mail belonged to. Deciding
that needs the amount; the amount lives inside the PDF; a Gmail preview
essentially never shows it. Measured over a real mailbox, every single
pairing came back "belopp ej synligt": it was answering without the
deciding evidence, which is why it declined five of six repeat
subscriptions and why two correct pairings sat just under a threshold.

Now it answers only what a subject, a sender and a preview line support:
is this mail an underlag, and which attachment is it. Then the receipt is
fetched, the extraction that already runs on document.uploaded reads its
amount, date and vendor, and the pairing is the same deterministic
amount-and-merchant match every other underlag goes through. Amount
becomes decisive for real rather than as an instruction the model could
not act on.

The load-bearing fix is small: ingest now copies the extraction result
onto the inbox item. The pool is read from invoice_inbox_items, so a
hunted receipt with no extracted_data could never have matched anything,
and the whole mail leg was quietly incapable of producing a pairing on
amount.

Consequences, all deliberate:
- Harvesting runs BEFORE the pool is read, so a receipt found tonight is
  paired tonight rather than a night later.
- One staging path instead of two. Mail-sourced proposals carry the same
  preview and confidence as every other, plus where they came from.
- Deduped on the attachment filename, not on the message: the same
  invoice arrives as an original, a reminder and two forwards, and the
  old key filed "Invoice_13041840.pdf" four times over.
- Capped at 8 receipts per merchant per run.

Measured on the same ledger: 5 pairings attempted from thin evidence ->
16 real documents identified, each waiting on an amount it can be checked
against.

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

* refactor(receipt-hunt): the model reads mail, arithmetic does the matching

Collapses the mailbox leg to one model call that extracts fields, and
hands every judgement back to deterministic code.

Gone: resolving bank descriptors to merchant names, deciding which mail
belongs to which charge, and the confidence score gating the result.
Three prompts and two model calls become one, and mail-intelligence.ts
drops from 450 lines to 250.

What made this possible was measuring what a mail actually contains. The
body was being downloaded and thrown away in favour of a 200-character
snippet, and the body is where a forwarded receipt quotes its original
sender and its original date. That is the purchase date, the thing whose
absence forced the date window off entirely and made the old design miss
five of six repeat subscriptions. It was there all along.

So the model now answers only what text can support: is this an underlag,
from whom, when, and for how much if the mail says so. Fields, not
judgements. Everything after is arithmetic:

- Retrieval is deterministic. No model decides what to search for.
- Fetching is gated by worthFetching(): a stated amount is enough on its
  own, a vendor needs a plausible date, and a mail found by a purchase's
  own search is evidence in itself. That last rule is what handles a
  supplier the bank and the invoice name differently ("Kontorsplatser j
  BG" against "Stockholm Innovation & Growth AB"), which is what the
  deleted merchant-resolution call used to buy.
- The pairing is the existing scorer, reached the same way as every other
  underlag: fetch, let the extraction that already runs on upload read
  the PDF, match on the amount. Amount is decisive in fact rather than as
  an instruction the model could not act on.

Also adds the Swedish thousands-space amount formats to the query.
Measured: the Sting invoice is findable as "15 000,00" and "15 000" and
by no ungrouped form at all, so every amount search was missing them.

Measured on the same ledger: 5 thin pairings -> 8 real documents, each
with a vendor and a true purchase date, waiting on the amount in its own
PDF. Currency is never converted to make a number agree.

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

* fix(receipt-hunt): trust the bytes, not the mail, when filing an attachment

Found by the first live run, which fetched nothing and reported success.
Three defects, each invisible to a dry run because a dry run never
downloads anything.

1. Gmail declares a forwarded PDF as application/octet-stream, and
   uploadDocument validates content against the declared type, so the
   upload was rejected: "Filinnehållet matchar inte den angivna
   filtypen". Every forwarded receipt with a generic MIME type would
   have failed this way, silently, since ingest swallows one bad
   attachment to protect the rest of the run. The type is now sniffed
   from the magic bytes, then the filename, and only then from what the
   mail claimed.

2. The filename was re-derived by a second full message fetch inside
   fetchAttachment, which came back empty and fell back to a generic
   "underlag.pdf", discarding the real "2332687551.pdf" the search had
   already reported. The known name now wins.

3. The provkörning script imported lib/init instead of calling
   ensureInitialized(), so document.uploaded reached no handler and
   nothing was ever extracted. It also used static imports, which are
   hoisted and ran before .env.local was read, leaving the extraction
   extension unable to build a Supabase client. Both are script defects,
   not product defects: the cron route calls ensureInitialized() at
   module level as the architecture requires. The script now loads the
   environment first and imports dynamically.

Also makes the per-run fetch cap tunable (RECEIPT_HUNT_MAX_RECEIPTS) so a
pilot can be held to a couple of documents, and adds --live to the
script, which is the only way it writes anything.

Verified end to end against a real ledger, every link exercised for the
first time: two attachments fetched from Gmail, stored with their real
names and types, extraction run on both, the amount copied onto the inbox
item, and the deterministic matcher pairing Elgiganten 21 639,00 kr from
the PDF against the -21 639 kr card purchase at 0.85, staged into
Granskning as attach_document_to_transaction. The second document, a
Bolagsverket filing receipt, carries no total and correctly paired with
nothing.

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

* feat(receipt-hunt): sweep a whole mailbox, and stop lending one receipt twice

A backfill on a real ledger, 22 documents fetched from 172 messages.

Batches the extraction (25 mails per call) so a first run on an existing
company can read the whole mailbox instead of the 40 mails one call can
carry, and makes the per-run caps tunable
(RECEIPT_HUNT_MAX_MAILS, RECEIPT_HUNT_MAX_RECEIPTS) so a pilot can be
bounded. The nightly caps stay where they are: they pace the review
queue, and a backlog is a different job from a nightly tick.

Two defects the backfill exposed, neither reachable from a dry run:

The one-receipt-one-purchase rule only held inside a single run.
`spentDocumentIds` is per-invocation, so an H&M receipt was proposed
against a -358 kr purchase on one pass and a -354 kr purchase on the
next, and approving both would have put the same underlag on two
verifikat. A live proposal now claims its document across runs, the same
way it already claimed its transaction.

A document reported with no filename, on a message carrying five
attachments, was not an answer but a shrug: the caller fetched
attachment number one and hoped. Those are dropped now. A body-only
receipt, where there is nothing to choose between, still passes.

Measured after the sweep: 21 of 22 documents read correctly, and the
binding constraint on this ledger is no longer retrieval but currency.
Ten receipts are in SEK and five of those pair on the amount; twelve are
in USD or EUR, where the bank charged a converted figure that appears
nowhere in the receipt, so no comparison is possible and none is
attempted.

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

* feat(mail): show the provider's own mark on the mailbox settings page

Someone connecting a mailbox is picking an account at a provider, and the
provider's mark is how they recognise which one. A generic envelope
glyph said "mail" when the question is "whose".

The Google "G" already existed, drawn inline inside GoogleAuthButton for
the sign-in flow. It moves to components/ui/provider-marks so there is
one definition rather than two, and a Microsoft square joins it for the
Graph connector. Both stay inline: no external host is contacted for an
icon before anyone has agreed to anything.

These are the only coloured glyphs in an achromatic interface, which is
deliberate rather than an oversight. A brand mark is identity, not
chrome, and Google's terms require its mark unaltered rather than tinted
to match a palette.

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

* fix(archive): drop the duplicate mail_connections exclusion left by the rebase

Main added the table to ARCHIVE_EXCLUDED_TABLES while this branch was
open, so rebasing produced the key twice and the zero-extension build
failed to type check. Main's entry stays, in its alphabetical place, and
keeps the sentence that answers the retention question: the grants are
not räkenskapsinformation, but the receipts they find are archived as
documents.

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

* fix(mail): record who disconnected a mailbox, without keeping the token

Raised by the compliance review: disconnect() hard-deleted the row with
no trace, and which mailboxes feed underlag into the books is a control
over how räkenskapsinformation is produced (BFNAR 2013:2 kap 8), so
switching one off should be reconstructable years later.

Written by hand rather than by the write_audit_log trigger the accounting
tables use. That trigger copies the whole row into audit_log, which here
would mean copying an encrypted refresh token into a second table and
keeping it after the entire point of the delete was to destroy it. The
sibling credential table shopify_connections omits the trigger for the
same reason. Only the address and provider are recorded, pinned by a test
that fails if a credential ever reaches the audit entry.

The review's two other flags were checked rather than assumed: nothing
purges mail_hunt documents, and categorize-core.ts:403 does carry the
attached document onto the verifikat when the transaction is booked.

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

* fix(mail): bound every outbound call, and stop the token widening itself

Four findings from the review, each checked against the code first.

Neither the Gmail API nor Google's token endpoint had a deadline. Both
are awaited inside Promise.all across mailboxes, so one stalled request
held the whole company's hunt open until the platform killed the run.
Both now carry a 15s AbortSignal, which turns a stall into one mailbox
missing from tonight's sweep.

`include_granted_scopes: 'true'` let Google fold scopes this app was
granted elsewhere into the token issued for a mailbox, so a grant could
carry more authority than the consent screen showed. Removed, and pinned
by a test asserting the parameter is absent.

disconnect() ignored both statement results: a failed delete still wrote
an audit entry claiming the mailbox was disconnected while the credential
was live, and a failed audit insert passed silently. The delete now
throws, so the entry is never written for a delete that did not happen.
The audit failure is logged rather than rolled back: the two can now only
diverge one way, credential gone and note missing, and recreating a
credential to keep them in step would be worse than a missing note.

The fifth finding is real and stays open by choice, recorded in
DECISIONS.md: the cron still passes searchMail=false. A sweep of one
172-message mailbox took over 600s against a maxDuration of 300, so
enabling the mailbox leg nightly would time out mid-run. That flag and
RECEIPT_HUNT_COMPANY_IDS get flipped together once the per-company budget
is measured.

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

* fix(receipt-hunt): file each attachment under its own identity

Four more findings from the review. The first is a real defect.

ingestMailCandidate loops over candidate.attachmentIds, but the dedupe
key, the mail_attachment_id provenance and the filename were all read
from index 0. Storing the second attachment therefore recorded the
first one's key and name, which mislabels the row and, because the key
is unique, permanently blocks the first attachment from ever landing.
Masked today only because the hunt narrows to a single attachment before
calling in, so nothing in the current path exercises it. All three now
come from the attachment actually being stored, and the duplicate
pre-check moved inside the loop so trying a second attachment is not
suppressed by the first already being filed. Mutation-tested.

The per-run fetch key was the bare filename, which is not an identity:
"invoice.pdf" is what half the world's billing systems attach, so a
second supplier's invoice would be dropped as a duplicate of the first.
Scoped by vendor as well, keeping the behaviour it was written for, one
fetch for an invoice that arrives as an original, a reminder and two
forwards.

Adds tests/pg/mail-hunt-file-dedupe.pg.test.ts for the new unique index:
five attachments from one forward all land, the same attachment is
refused twice, two companies hold the same file independently, other
inbox sources are untouched by the partial predicate, and the
message-scoped predecessor is gone. Written against CI's Postgres; there
is no local DATABASE_URL here, so CI is what exercises it.

--live now refuses unless RECEIPT_HUNT_CONFIRM names the same company.
The script writes to whatever .env.local points at, which for this repo
is production, and a recalled command should not be able to fire it.

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

* fix(test): cast the jsonb parameter so Postgres can type it

pg-real could not determine the type of $3 inside jsonb_build_object.
An explicit ::text is what the other pg tests do.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:23:42 +02:00
Mattsson d4b0bd3df1 feat(invoices): expose the automatic reminder kill switch in settings (#1476)
* feat(invoices): expose the automatic reminder kill switch in settings

The send_invoice_reminders column, API schema, and cron processor check
already existed, but no UI ever exposed the toggle. Add a switch in
Settings -> Fakturering (day thresholds fold away when off, values
preserved), and make the invoice detail Paminnelser card say reminders
are off instead of promising emails that will never be sent.

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

* fix(invoices): do not assume reminders enabled before settings load

CodeRabbit finding on PR 1476: with no company_settings row loaded, the
Paminnelser card defaulted to promising the reminder schedule. Track the
toggle as boolean | null and render no schedule text until the settings
row has actually resolved.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:24:15 +02:00
Jakob Wennberg 741cdcc60e feat(onboarding): completion signature when the checklist finishes (#1487)
* feat(onboarding): completion signature when the checklist finishes

The getting-started block used to vanish the instant its last step
completed: a useEffect PATCHed completed:true and the component returned
null, so the arc's payoff was a silent disappearance.

Now the session that finishes the last step gets the completion beat the
activation analysis prescribed: the JourneyOrb check-morph (same particle
idiom as the journey, deliberately not confetti) with one verdict line,
'Bokföringen är igång.', held for a beat before the block fades and
retires. The beat latches via a ref so a failed completion PATCH can
retry without replaying it, and the PATCH still fires first so completion
is durable even if the user navigates away mid-beat. Companies whose
completedAt arrives from the server never see the beat: it plays exactly
once, in the finishing session.

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

* fix(onboarding): hold the retired state while the completion PATCH is in flight

PR Agent on #1487: if the completed-PATCH takes longer than the 3.2s
beat, retiring fell back to null with completedAt still unset, so the
full checklist flashed back in after the verdict had already played. The
beat now ends in a terminal 'done' phase that keeps the block retired
for the rest of the session; a failed PATCH keeps retrying invisibly and
the next visit renders from server truth either way.

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-10 09:57:38 +02:00
Jakob Wennberg d1b938d869 chore(onboarding): delete the dead first-run components and their orphaned strings (#1488)
The activation analysis (2026-08-07) verified these have zero importers or
triggers; the new stepped checklist and its PostHog funnel replaced their
roles:

- SuccessAnimation (confetti variant): never mounted, and the completion
  moment is now the quiet check-morph signature, so it will stay unused
- AgentSetupBanner, ConsultantEmptyState: never mounted
- EmptyReceipts + its Camera import: only consumer of the preset_receipts_*
  strings and of a /receipts/scan route that does not exist
- new_user_checklist i18n namespace (24 keys x 2 locales): the old wizard
  copy; the live checklist reads initial_setup

The onboarding.empty agent intent stays: it is registered in the intent
table and reachable via /api/agent/invoke, so removing it is a product
decision, not dead-code removal.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:51:40 +02:00
Jakob Wennberg 51bf1b77bf feat(import): mount the knowledge-graph theater on the Arcim migrating step (#1486)
* feat(import): stream real Arcim migration progress as NDJSON (#1485)

* feat(import): stream real Arcim migration progress as NDJSON

The /migrate route ran the orchestrator to completion and answered with one
JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and
a static step label for a phase that can take minutes. The orchestrator has
had a real onProgress channel (eight emit points with Swedish step labels
and anchors) since it was written; the route just never passed it.

Now a request with Accept: application/x-ndjson gets a streamed response:
one line per orchestrator progress event, then a terminal done line with
the results or an error line carrying the same structured envelope the
JSON path returns (the 200 status is already committed once the stream
opens). Callers without the header keep the original single-JSON contract,
so pre-deploy tabs and the existing error-mapping tests are untouched.

The wizard opts in, drives MigratingStep from the real labels and anchors
(mapped onto the 55-100 slice of the wizard bar), and treats a dropped
connection as unconfirmed rather than failed, since the migration keeps
running server-side and a blind retry could double-import.

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

* docs: record the opt-in NDJSON streaming decision for /migrate

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>

* feat(import): mount the knowledge-graph theater on the Arcim migrating step

The migration wizard already holds the fully parsed SIE client-side
(SIEData.parsed from /sie-data), so the same TheaterCanvas that carries
the /import flow can build the company's knowledge graph while the
migration runs: no server change, and the plain progress card stays as
the fallback whenever no parsed SIE exists (e.g. providers without SIE).

Unlike /import's fixed narration script, the wizard knows exactly what
the server is doing: phase 1 posts one SIE file at a time and phase 2
streams the orchestrator's real progress events. ArcimMigrationTheater
therefore narrates by printing those real step labels once each as they
arrive, and keys the canvas to the same milestones: the GL skeleton
(rings, buckets, accounts) builds during the journal writes, counterparty
waves attach while customers and suppliers import, and reconciliation
pulses. Real progress bar and elapsed counter stay visible throughout.

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

* fix(import): review fixes on the migration theater and stream close

CodeRabbit on #1486: (1) the aria-live region wrapped the per-second
elapsed counter, so a screen reader re-announced the timer every second
and drowned out the real step labels; the live region now covers only the
narration list and the timer row is aria-hidden (the Progress bar exposes
its own ARIA value). (2) controller.close() in the stream's finally block
throws if the reader already cancelled, escaping start() as an unhandled
rejection; now guarded like send().

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-10 09:43:32 +02:00
Jakob Wennberg 1d01010928 feat(import): stream real Arcim migration progress as NDJSON (#1485)
* feat(import): stream real Arcim migration progress as NDJSON

The /migrate route ran the orchestrator to completion and answered with one
JSON blob, so the wizard faked its progress bar: a hardcoded 55% anchor and
a static step label for a phase that can take minutes. The orchestrator has
had a real onProgress channel (eight emit points with Swedish step labels
and anchors) since it was written; the route just never passed it.

Now a request with Accept: application/x-ndjson gets a streamed response:
one line per orchestrator progress event, then a terminal done line with
the results or an error line carrying the same structured envelope the
JSON path returns (the 200 status is already committed once the stream
opens). Callers without the header keep the original single-JSON contract,
so pre-deploy tabs and the existing error-mapping tests are untouched.

The wizard opts in, drives MigratingStep from the real labels and anchors
(mapped onto the 55-100 slice of the wizard bar), and treats a dropped
connection as unconfirmed rather than failed, since the migration keeps
running server-side and a blind retry could double-import.

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

* docs: record the opt-in NDJSON streaming decision for /migrate

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-10 09:26:27 +02:00
Jakob Wennberg ce1e0b7642 feat(banking): the bank-connect payoff (#1477)
* feat(banking): the bank-connect payoff: point the first sync onward

Fifth activation slice. Every successful connect passes through the
sync-progress dialog's done state, which used to end on a bare Klar
that stranded the user on the settings panel. Now it is the payoff:
the imported count stays, the work now waiting gets named (N att
bokföra · M matchar fakturor, from the same worklist counts endpoint
the dashboard pane refetches), and the primary action becomes
Visa N att bokföra -> /transactions, where realtime rows, the match
pills and the Att bokföra badge already deliver the rest. Zero-import
syncs keep the plain Klar.

Also mounts the fully-built-but-orphaned BankSyncSinceLastVisit pill
on the transactions footer, so returning users get the same payoff
line for the nightly cron (N nya transaktioner sedan sist).

Strings stay hardcoded Swedish inside the extension, matching every
neighboring string in the dialog.

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

* fix(banking): review triage: no stale counts, no marker advance on error

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-09 20:52:25 +02:00
Jakob Wennberg 170b2722d6 feat(import): the Din historia reveal after SIE import (#1473)
* feat(import): the Din historia reveal after a successful SIE import

Fourth slice of the activation concept, stacked on the theater (#1471).
When the theater ran, the result step's success header becomes the
reveal: the settled constellation beside the personalized story
({years} år av historia, verifikat/konton/motparter, the balance
tie-out) and the bank bridge ("Historiken är på plats. Det som saknas
är nuet: banken.") deep-linking into the bank connect flow.

Honesty guards from the adversarial pass: the reveal requires actually
imported entries (an opening-balances-only run keeps the plain header),
the balance claim is suppressed when unbalanced vouchers were skipped,
and sandbox hides the bank bridge (live connections are stripped from
/import there). Failures and theater-less successes render the exact
previous header + stats grid.

The canvas is extracted to a shared TheaterCanvas (build mode driven by
the narration timeline via an imperative handle; settled mode for the
reveal: everything born, camera home, breathing only).

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

* fix(import): tame the constellation labels against real-world data

Real BAS names are paragraph-length and real files cluster in one class
region, which piled fifteen full labels into mush (founder screenshot,
Arcim Technology import). Labels now truncate hard (24/16 chars), only
the five heaviest accounts and counterparties carry labels, spread and
radius widen within a bucket, and a greedy per-frame collision pass
skips any label that would overlap one already drawn (importance order:
hub, buckets, heaviest first). The reveal also drops its meta line:
less text, the story is the headline + stats + bridge.

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-09 20:05:00 +02:00
Jakob Wennberg d4ef4f8bc4 feat(import): the import theater during SIE execute (#1471)
* feat(onboarding): branch question on the journey done screen

Second slice of the approved activation concept: the moment the company
exists, the done screen asks "Var fanns bokföringen innan?" with
provider chips (real logos), SIE file, and new-business options, plus a
quiet look-around escape. Choices persist initial_setup_path
(fire-and-forget) and deep-link into the existing flows: providers jump
straight to the migration wizard's connect step (sieViaApi providers
only; Visma/Bokio land on the provider list where the SIE-first gate
lives), the SIE chip opens the upload step, new business lands on Hem
with step one checked off.

mode='add' keeps the plain "Öppna Accounted" button: the concept's own
guard, and it avoids writing the path onto the previous company if
setActiveCompany silently failed. Routing lives in a pure helper with
tests; anonymous onboarding_branch_chosen funnel event follows the
guarded capture pattern.

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

* fix(onboarding): review triage: single-choice latch, preselect reset

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

* fix: restore package-lock.json to main (worktree npm install mutated it)

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

* feat(import): the import theater: a knowledge graph draws itself during SIE execute

Third slice of the activation concept. While the SIE import commits
server-side (one opaque call, up to ~5 min), the client parses the same
file locally (the parser is browser-clean) and a canvas constellation
builds itself: company hub, fiscal years as tree rings, account-class
anchors, top accounts and recognized counterparties, with paced
narration lines alongside. The final line holds with the elapsed counter
until the server answers, so the theater never outruns the truth.

- lib/import/theater-model.ts: pure aggregation of ParsedSIEFile into a
  capped display model (14 accounts, 12 counterparties, >=2 sightings,
  internal accounting texts skipped, counterparty attached to its
  counter account rather than the bank leg). Tested with fixture-string
  SIE per the sie-parser test pattern.
- components/import/ImportTheater.tsx: ink-on-paper canvas + narration,
  tokens read per frame (theme/palette reactive, JourneyOrb idiom),
  reduced motion renders the settled graph and all lines instantly.
- Wizard: client parse kicks off at execute start via dynamic import,
  capped at 8 MB; any failure silently leaves the existing spinner
  takeover, which also remains for oversized files.

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-09 19:58:33 +02:00
Mattsson 622b144a3d fix(agent): let non-payers dismiss the upsell FAB for the session (#1475)
A user without the AI capability could not get rid of the floating
"Uppgradera för att använda {namn}" pill: it had no dismiss of its own,
and closing the paywalled agent sheet just brought it back, leaving a
wide overlay pinned in the bottom-right corner (reported by a user via
Discord).

The pill now carries an X segment (non-payer, fresh state only) and a
non-payer closing the agent sheet counts as the same dismissal. Both
hide all floating assistant UI for the rest of the browser session via
sessionStorage; a new session shows the pill full-size again, so the
conversion surface is muted per session, never silenced permanently.

Payer behavior and the collapsed-session handle (the only way back to a
minimized conversation) are unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:40:58 +02:00
Jakob Wennberg 02a5d10538 refactor(ui): migrate remaining inline pages to the concept design language (#1470)
* refactor(ui): migrate remaining inline pages to the concept design language

Catch-up pass for surfaces the 2026-07 UI migration missed:

- Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade
  verifikationer, ignorerade, matchade), instructional copy moved behind the
  page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty-
  dates hint, EmptyState for the blank page, space-y-8 rhythm.
- Report detail views (trial balance, income statement, balance sheet,
  resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L):
  shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened
  to hairline cards with font-display tabular-nums headline numbers,
  ReportSectionTable rebuilt on the group-band idiom, font-mono money ->
  tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed
  onto Input primitives, verdicts follow chips-mark-exceptions.
- Extensions browse: PageHeader, locked section headers, rounded-lg
  secondary icon tiles, flat hover shift on cards, p-6 content.
- Återkommande fakturor: page-level list moved off ui/table onto dry-table
  with hover-revealed quiet row actions; Skeleton loading.
- Help: EmptyState for no search hits, flat hover shift on resource links.
- Chart of accounts: spinner loading blocks -> Skeleton rows.
- Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea
  primitives instead of hand-rolled shadow-sm controls.

No logic, endpoint, or data changes. Verified via sandbox screenshots;
lint 0 errors, 13214 tests green.

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

* fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit

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-08 16:40:19 +02:00
Jakob Wennberg 645ed0a53e feat(login): method-state login panel with quiet inline errors (#1469)
* feat(login): method-state login panel with quiet inline errors

The login panel now shows one method at a time (the pattern Swedish
users know from banks, Kivra and Fortnox): BankID as the hero state,
the email form as a peer state, and the remaining methods as two quiet
half-width chips under a single divider. The last successful method is
remembered in an accounted-login-method cookie, read server-side so a
returning password user gets the form on the first paint with no flash.

Error display drops the boxed banner everywhere: credential failures
render as one destructive sentence directly under the password field
(fields keep aria-invalid), and the reset-password action surfaces from
the second consecutive failure. BankID/Google failures, callback errors
and the session-timeout notice are single quiet lines at the top of the
panel (AttnLine for the informational one).

Also: password visibility toggle, webkit autofill repaint to the theme
surface, auth pages move from the gradient background to the app frame
tone, register/MFA/reset get the same backdrop for cross-page
coherence, and Skapa konto moves out of the panel into a footer line.

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

* feat(register): mirror the method-state panel on signup

Same treatment as the login page: BankID signup as the hero state, the
email form as a peer state (live password checklist kept), alternatives
as half-width chips under one divider, quiet-line notices instead of
the blue box, subtitle dropped, footer harmonized. Successful signup
persists the method hint so the user's first login opens correctly.
The BankID-verified email-collection step keeps its panel takeover.

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-08 16:33:50 +02:00
Mattsson b4b7549004 feat(agent): resizable, undockable assistant panel (#1467)
* feat(agent): resizable, undockable assistant panel

User report: the assistant chat sheet sometimes covers the page content
the user is asking about, with no way to resize or move it.

- Docked mode is now drag-resizable from its left edge (380-800px,
  clamped so the page keeps a 480px readable column) and the page
  reflows beside it via the existing --agent-dock-w reservation.
- Expanded (focus) mode reserves page margin like the compact dock
  instead of overlaying up to 1100px of the page.
- New undock toggle turns the sheet into a floating window that can be
  dragged by its header and resized from edges/corners, clamped so the
  header always stays reachable. Desktop only; mobile keeps the
  full-screen sheet.
- Geometry (mode, dock width, float rect) persists per user in
  user_preferences.ui_state.agent_panel, server-seeded to avoid a
  first-paint jump; the ui-state API schema gains a strict agent_panel
  key with nested merge.
- Pure clamp/resize math lives in lib/agent-panel/geometry with unit
  tests; drag frames write styles imperatively and commit one
  preference update on release.

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

* fix(agent): address review findings on panel drag, a11y, and persistence

CodeRabbit round 1, all six findings fixed:

- Bind drag listeners to window (plus lostpointercapture) so a failed
  pointer capture or mid-drag unmount can never leave the transition
  suppression and data-agent-resizing stuck for the session.
- Keyboard resize now steps from the visible width (expandedW in focus
  mode) instead of jumping to the persisted dock width.
- The width handle exposes window-splitter semantics: aria-valuenow,
  aria-valuemin, aria-valuemax.
- --nav-w is read reactively via a MutationObserver on #dash-shell
  instead of computed-style reads in the render body and per drag frame.
- The ui-state POST in updatePanelPrefs gets a 300ms trailing debounce
  (state stays immediate) so key auto-repeat cannot produce one
  read-merge-write per repeat; pending write flushes on unmount.
- globals.css keeps one :root token block; the agent-resizing rule moved
  below it.

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

* fix(agent): filter drag events by pointer id, clear fired debounce timer

CodeRabbit round 2, both findings fixed:

- Window-level drag listeners now ignore events from pointers other than
  the initiating one, so a second touch or pen cannot move the panel or
  end the first pointer's drag.
- The persist debounce timer ref is nulled when the timer fires, so the
  unmount flush only writes genuinely pending values instead of
  replaying an already-persisted (possibly stale) geometry.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:13:02 +02:00
Mattsson 39f4ecdad4 fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

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

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

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

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:04:58 +02:00
Jakob Wennberg c7dd78b0a3 feat(onboarding): branch question on the journey done screen (#1468)
* feat(onboarding): branch question on the journey done screen

Second slice of the approved activation concept: the moment the company
exists, the done screen asks "Var fanns bokföringen innan?" with
provider chips (real logos), SIE file, and new-business options, plus a
quiet look-around escape. Choices persist initial_setup_path
(fire-and-forget) and deep-link into the existing flows: providers jump
straight to the migration wizard's connect step (sieViaApi providers
only; Visma/Bokio land on the provider list where the SIE-first gate
lives), the SIE chip opens the upload step, new business lands on Hem
with step one checked off.

mode='add' keeps the plain "Öppna Accounted" button: the concept's own
guard, and it avoids writing the path onto the previous company if
setActiveCompany silently failed. Routing lives in a pure helper with
tests; anonymous onboarding_branch_chosen funnel event follows the
guarded capture pattern.

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

* fix(onboarding): review triage: single-choice latch, preselect reset

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

* fix: restore package-lock.json to main (worktree npm install mutated 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-08 15:27:17 +02:00
Jakob Wennberg 44cff5e5e4 feat(onboarding): activation quick wins on the Hem checklist (#1461)
* feat(onboarding): activation quick wins on the Hem checklist

First implementation slice of the approved activation concept
(artifact de543d57, dev_docs/onboarding_activation_analysis.md §9):

- New "Kvitton och underlag" checklist step, gated on the invoice-inbox
  extension like the Skatteverket step; done once the company has ever
  received an inbox item (email/WhatsApp/upload). Non-AI companies route
  to billing, matching the assistant step.
- Personalized VAT line in the Skatteverket step: the company's real next
  momsdeklaration due date from the deadlines table, with an explicit
  "välj momsperiod" prompt when vat_registered is set but moms_period is
  null (that state silently generates zero VAT deadlines).
- Truthful Att göra empty state: while the setup checklist is open and no
  journal entry is posted, the all-clear reads "Bokföringen är tom än"
  instead of a false "Allt klart!".
- Activation funnel events (onboarding_setup_step_started / _completed /
  _dismissed) via posthog-js, mirroring the existing guarded capture
  pattern; sandbox never renders the block so no extra gate is needed.

Pure helpers live in lib/onboarding/checklist.ts with tests; step
numbering now adapts to both optional extensions.

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

* docs: record the receipts-signal and moms-period-guard decisions

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

* fix(onboarding): review triage: error-safe emptyLedger, stale-state guard, copy

- A failed posted-entries count no longer reads as an empty ledger.
- Confirming a suggested match books an entry, so the empty-ledger copy
  retires for the rest of the session (postedSinceLoad).
- 'Bokföringen är tom än så länge' reads naturally.

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-08 15:14:34 +02:00
Mattsson 799fa1246a fix(vat): downgrade per-voucher RC basis gaps only under per-rate evidence (#1464)
* fix(vat): downgrade per-voucher RC basis gaps only under per-rate evidence

Per-voucher RC basis gap findings (findRcBasisGaps) blocked "Skicka till
Skatteverket" as ERROR even when the flagged vouchers were legitimate
moms-only rattelseverifikat whose basbelopp lives in another (often
reversed) verifikat. In that state no arrangement of vouchers satisfies
both the per-voucher scan and the aggregate basis/moms identity, so the
block was unfixable: every correction voucher joined the blocklist it
was meant to clear (Orto Engineering 3DJake support case, 2026-08).

The gap finding now downgrades to a non-blocking WARNING only when ALL
of the following hold, otherwise the blocking ERROR stays exactly as
before:

- the 44xx/45xx RC basis accounts, grouped per momssats
  (RC_BASIS_ACCOUNTS_BY_RATE), match ruta 30/31/32 two-sided within a
  0.5 kr ore epsilon per rate;
- no moms box (ruta 30/31/32) is negative;
- the aggregate RC_OUTPUT_MISSING check has not fired;
- the caller supplied the evidence at all (older wire payloads and
  totals-less contexts keep the blocking behavior).

A first cross-rate-sum predicate was refuted by adversarial review: a
wrong-rate fiktiv moms voucher (12% moms "covered" by a 25% basis)
reached parity and unblocked a 7 800 kr under-declaration, and a
net-negative rate box made the summed comparison vacuous (textbook
FK004 state filing). Rutor 20-24 are partitioned by purchase type, not
rate, so the certificate must come from account totals; both
counterexamples plus the tolerance-hole case (shortfall inside the
aggregate 0.5% tolerance still blocks) are locked in as regression
tests.

The evidence travels as rcBasisByRate on the declaration payload
(rcBasisTotalsByRate projection), consumed by the web view and the MCP
completeness checks; rc-basis-gaps.ts derives its flat account set from
the same rate-grouped single source so scan and evidence cannot drift.

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

* fix(vat): refuse gap downgrade on non-finite evidence; pin the ore epsilon

Review findings, one pass:

- CodeRabbit (major): rcBasisByRate arrives as unvalidated JSON in the
  web view; a missing or non-numeric field made every per-rate
  comparison evaluate against NaN, which compares false and PASSED the
  predicate, relaxing the filing gate in the unsafe direction. The
  predicate now refuses the downgrade outright on any non-finite basis
  or moms figure, covering both the web and MCP callers.
- CodeRabbit (nit): added a 0.51 kr drift case so a future widening of
  the 0.5 kr epsilon fails a test instead of slipping through green.

Declined with reasons (recorded in the PR summary): requiring textual
voucher-to-voucher references before downgrading (belongs to the
rattelse documentation flow, and would reintroduce the unfixable block
this PR removes); epsilon stacking across rates (max 1.5 kr, immaterial
at whole-krona filing and below the aggregate tolerance); explicit
negative-basis guard (all negative-basis paths already block via the
two-sided mismatch or the negative-moms guard, now plus the finite
guard).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:45:22 +02:00
Jakob Wennberg 70845edf69 feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name

Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:

- transactions.transaction_method (text + CHECK closed vocabulary: card,
  transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
  deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
  bank_transaction_code / proprietary_bank_transaction_code evidence
  columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
  explicit source method (Stripe txn.type) > trailing Swedish channel
  phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
  It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
  as original_description; dedup is untouched (external_id is date+öre,
  the content bridge reads original_description and is prefix-based, and
  a trailing strip leaves a prefix). Enable Banking passes the codes
  through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
  (+ MCC and Stripe prefixes) and strips unedited titles; user-edited
  titles are never rewritten.
- mapping-engine also matches original_description so user rules written
  against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
  "Betalsätt: Kortköp" etc. (sv/en), making every classified row
  expandable.

A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.

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

* fix(transactions): anchor counterparty-template identity on original_description

Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.

Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.

Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).

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

* fix(transactions): review follow-ups on method classification

- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
  refinement on the proprietary code beats a bare family match on the
  ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
  that only match original_description, including the invalid-regex
  substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
  mapping to 'fee'.

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

* fix(transactions): scope method classification to feed rows + adjective guard

Multi-bank risk hardening before the backfill ships:

- Feed-row scope: classification and title stripping now require a real
  import feed (import_source present, not manual/mcp), both at the
  ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
  every backfill statement. User-authored titles like "Egen insättning"
  on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
  in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
  is skipped, so "Egen insättning" stays whole even on bank-feed rows;
  the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
  phrase means no method and no rewrite, so the worst case for any bank
  whose vocabulary we have not seen is the status quo.

Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).

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

* chore(migrations): re-timestamp transaction_method migrations after rebase

Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.

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

* chore(migrations): bump transaction_method versions past prod's max

Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.

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

* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker

- TransactionInboxCard: row-level Enter/Space handling now ignores events
  bubbling from nested controls, so keyboard activation of Bokför / the
  overflow menu is no longer cancelled by the (now much more common)
  expandable row.
- Feed predicate parity with isImportedTransaction(): a live
  bank_connection_id marks a feed row even when import_source is unset
  (the oldest PSD2 rows predate that column), in both the ingest
  classifier and every backfill statement: those legacy rows now get
  classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.

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

* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail

Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.

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

* fix(migrations): enforce, not assume, original_description preservation in the title strip

The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.

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

* docs: record the compliance-review triage of the backfill's booked-row title strip

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-08 11:58:51 +02:00
Jakob Wennberg a49d75db77 fix(migration): Visma pagination + chunk-insert resilience (the '300 misslyckades' case) (#1455)
* fix(providers): paginate Visma eAccounting with $page/$pagesize

eAccounting silently ignores OData $top/$skip, so every request returned
page 1 and getPaginated appended the first page TotalNumberOfPages times:
customers were imported in triplicate and invoice chunks hit unique
violations. Also stop on an empty page so a stale Meta can never loop or
duplicate.

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

* fix(migration): survive bad rows in entity imports instead of failing whole chunks

One PostgREST insert per 500-row chunk is all-or-nothing, so a single
duplicate reported every row as failed ('300 misslyckades') with no cause
shown. Now: dedupe repeats within the fetched data (paging faults, source
duplicates), fall back to per-row inserts when a chunk is rejected, store
empty invoice numbers as NULL instead of colliding '', surface the first
DB error in the result UI, and mark all-failed steps with an error icon.
Sales invoices also carry remaining_amount so open invoices no longer
land as settled.

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

* fix(migration): never per-row retry after a successful bulk insert with short read-back

A succeeded statement whose .select() returns fewer rows than sent means
the rows ARE in the table; retrying them one by one would duplicate every
unreturned row. Pair what came back and report the tail instead.

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

* fix(migration): count stub-insert casualties as failed and sample enrichment errors

Review follow-ups: invoices dropped because their customer/supplier stub
insert errored are DB failures, not matching misses; classifying them as
noMatch rendered a green result row with the database error hidden.
Enrichment failures now also feed errorSample.

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-08 10:45:02 +02:00
Jakob Wennberg 858ad49852 fix(booking): explicit Ingen moms in quick review books exempt, not the derived default (#1450)
QuickReviewDialog collapsed 'none' to undefined on the wire, so the server
re-derived the category default and booked 25% moms against an explicit
no-VAT choice while the preview showed no VAT line. 'none' now maps to
'exempt' only when it deviates from the seeded default: untouched bookings
on exempt-default categories (bank fees) stay byte-identical, an explicit
Ingen moms books no VAT line with the classification the momsdeklaration
should see, and the class-2 account auto-'none' stops booking a stray moms
line. Same gap the swedish-compliance bot flagged on #1443.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:58:46 +02:00
Mattsson 7c93d53fd5 chore(nav): hide Korjournal from the sidebar (#1453)
The /mileage route and all mileage functionality stay live; only the
sidebar entry is hidden.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:46:21 +02:00
Mattsson 7411a0171b feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:17:21 +02:00
Mattsson 93f81f03e8 feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED

Adds WINT (wint.se) as a sixth migration provider, built against the
OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the
partner-facing v1 endpoints are used; the general ledger is fetched as
vouchers/accounts and rendered as SIE 4E by our own sie-builder, with
opening balances for earlier years derived backward from the current-year
Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair;
the password is never stored.

Ships dark: the wizard shows a disabled "Kommer snart" card, and the
server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true.
Live verification against a real WINT account is still outstanding.

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

* fix(providers): harden WINT provider per PR #1446 review findings

Addresses CodeRabbit and Swedish accounting review feedback in one pass:

- Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an
  active year outside the allowed import window can never silently anchor
  the wrong year; the voucher chain is extended through the anchor and a
  per-year fetch failure fails that year loudly instead of sinking the
  whole migration.
- Auth token exchange is strict: only LoginState Success with a complete
  access+refresh pair mints a consent (a pair without a refresh token is
  unrefreshable and would break days later).
- WintApiError no longer retains full response bodies (bounded 300-char
  diagnostic; bodies can carry customer data and errors get logged).
- sie-builder refuses to render structurally invalid vouchers (missing
  account number or booking date) and documents deleted-voucher gaps in a
  #PROSA record per BFL 5 kap 6-7 §.
- Account classification: 20xx is equity, 83xx is financial income.
- SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it
  previously produced a false non-BAS warning on every WINT/Bollbok file).
- New tests: resolveConsent WINT refresh flow, credential upsert payload
  (no mail/password persisted), WINT fetch failure path, EUBAS97 warning
  regression, builder invalid-data rejection, vi.clearAllMocks hygiene.

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

* fix(import): pin EUBAS97 acceptance to the exact SIE spec value

Review follow-up on PR #1446: match EUBAS97 exactly instead of any
EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four
kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97,
NE2007) rather than silently accepting unknown future variants.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:07:14 +02:00
Jakob Wennberg c0a106e591 feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date

BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the
initial VAT treatment, overriding the server's per-category derivation and
claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit
'Enligt kategori' option that omits vat_treatment so the server derives it
(exempt bank/card fees, 12% representation). Reverse charge is never derived.

The embedded JournalEntryForm period Select is replaced by the same derived
read-only text the standalone variant already uses: the period is a total
function of the entry date, and the Select allowed picking a period that
disagreed with it.

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

* feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt

BookDirectlyDialog and the supplier-invoice form left the cost account
deliberately blank even when the company's own confirmed history for the
counterparty (categorization_templates) or supplier.default_expense_account
knew the answer. Both now prefill from a counterparty-template hit (new
?counterparty= single-match mode on the settings route, same tiered matcher
as the booking flows), only into still-empty fields, only from expense-shaped
templates, with a provenance line. No generic fallback: a miss leaves the
field blank exactly as before.

Bokfor direkt's period Select is replaced by text derived from the entry
date; the silent periods[0] fallback becomes a blocking explanation, since
borrowing an arbitrary period could book into the wrong one.

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

* feat(ux): single-company login skips the picker; filing surfaces default to filable periods

/select-company auto-forwards when the user is a member of exactly one
company with nothing else to decide (no new TIC engagements, no pending
invite, enrichment fresh); the in-app 'Lagg till foretag' links pass
?choose=1 to keep the picker deliberately reachable. Byra/multi-company
users are untouched.

The VAT declaration now opens on the most recently ENDED month/quarter
(lib/vat/period-defaults, tested) instead of the current one, which can
never be filed and forced a step-back click on every filing visit; the
periodicity switch resets the same way. Helarsmoms FyPicker gains
preferLatestEnded and opens on the latest ended rakenskapsar instead of
the newest started one.

The 'momsperiod saknas' dead end now collects the answer inline through
the same PUT /api/settings validation instead of bouncing to settings:
until the period exists the deadline engine generates zero VAT deadlines,
silently, so every extra hop kept a compliance hole open.

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

* feat(granskning): approve pill commits directly for low and medium risk

The Godkann pill on /pending only opened a ConfirmationDialog demanding a
second Godkann, regardless of tier. The review row already states source,
title and risk and offers Detaljer, so for low/medium the pill now commits
directly; high risk keeps the dialog, whose warning sentence carries
information the row does not. Chat-side bulk approve is deferred: it needs
ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md.

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

* fix(reports): map inline momsperiod save errors through getErrorMessage

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

* fix(review): repair the dead login auto-forward and nine review findings

The big one: setActiveCompany ends with a cookie write that throws during
Server Component render (sealed cookie store), so the /select-company
auto-forward silently never fired; the write is now best-effort since the
cookie is write-only compat and the DB write is already verified.

Also: supplier-switch un-plants history-prefilled accounts so the new
supplier's own default applies; prefill routes through handleAccountChange
so konto default moms rides along; batch 'Ingen moms' books exempt instead
of the derived 25%; monthly VAT default tracks the actual 12th/17th filing
deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState,
gates on vat_number (the PUT would 400 without it), keeps keyboard focus
and announces errors; cost-account shape guard tightened to P&L accounts;
attn tone on the new warning lines.

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

* ci: retrigger workflows; the Actions outage swallowed the rebase push event

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

* ci: retrigger after outage (events dropped, not delayed)

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

* ci: retrigger after GitHub Actions recovery

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

* fix(review): address CodeRabbit and compliance-bot findings

Direct commit now prunes the op from the bulk selection (a stale id kept
inflating the bulk bar and rode into bulk-commit) and the detail-panel
Godkann gets the same risk gate as the row pill. The automatic account
fill in the supplier-invoice form is requested, not applied inline: the
applying effect waits for both the BAS chart and the request with fresh
closures, so a fill can no longer land before the chart and leave a
VAT-free konto on the 25% row default. Test dates use local-time
constructors (ISO strings parse as UTC midnight and shift a day in
negative-offset timezones). Stale ML 11 kap citation dropped from a
comment.

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

* ci: retrigger; push event dropped again

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-07 10:16:23 +02:00
Jakob Wennberg 63d520719a fix(import): treat a voucher-less SIE file as a no-op, not a failed migration (#1445)
* fix(import): treat a voucher-less SIE file as a no-op, not a failed migration

A Fortnox migration aborted with the generic "Något gick fel. Försök
igen." when the current fiscal year had nothing booked yet: Fortnox
exports an empty SIE file for such a year, the finalizer's 0-entry
safety net flipped it to 'failed', and the wizard stopped before the
customer/supplier/invoice phase ever ran.

Three layered fixes:

- finalizeImportRecord only downgrades a 0-entry run to 'failed' when
  the file actually contained vouchers (parsed count via the
  documentation object). A file with no vouchers completes as a no-op
  with an explanatory warning; the mapping-fix retry loop the downgrade
  exists for (Lookma case) is unchanged.
- The migration wizard no longer routes messages that are already
  user-facing Swedish (server envelopes, ImportResult.errors) through
  getErrorMessage's Swedish-pattern heuristic, which swallowed
  unrecognized sentences into the generic fallback.
- The heuristic itself learns the import-error family
  (verifikation/importera) so other surfaces rethrowing engine
  messages keep the real reason too.

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

* fix(import): cross-check raw #VER before accepting a 0-voucher file as empty

The parsed voucher count alone cannot prove a fiscal year was empty: a
field-separator or encoding mismatch can swallow every #VER block with
only a warning-severity parse issue, and executeSIEImport does not fail
on those. Only a raw content check proves the file never declared any
vouchers. Addresses the truncation/corruption finding from the Swedish
compliance review on #1445.

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

* docs: record the raw #VER safeguard in the empty-SIE-file decision entry

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-07 09:45:29 +02:00
Jakob Wennberg 28b58aedc4 feat(auth): sign in with Google behind NEXT_PUBLIC_GOOGLE_AUTH_ENABLED (#1441)
Adds a 'Continue with Google' button to login and register, gated by
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED so it ships dark until the Google
provider is configured in Supabase. The OAuth round-trip reuses the
existing /auth/callback PKCE exchange, which already owns MFA routing,
invite acceptance and silent-team creation. A flow=oauth marker on the
redirect lets the callback tag failures (including provider consent
denials) so the login page shows Google-specific error copy instead of
the email-confirmation framing.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 21:26:09 +02:00
Jakob Wennberg 0bb0b89353 feat(auth): inline, specific error states on login and signup (#1440)
* feat(auth): inline, specific error states on login and signup

Auth failures now render inline next to the form instead of as a
top-right toast: a persistent alert with role=alert, aria-invalid
field highlighting, and focus returned to the offending field.

Login maps GoTrue error codes (invalid_credentials, email_not_confirmed,
rate limits, user_banned) to specific Swedish/English messages, with a
reset-password link embedded in the credentials error. The credentials
message stays 'wrong email or password' by design: GoTrue returns one
code for both cases to prevent account enumeration.

Signup gets a live password-requirements checklist, field-level errors
for weak/mismatched passwords, and inline handling of email-exists,
invalid-email and rate-limit responses with a sign-in link where that
is the recovery path.

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

* fix(auth): treat email_provider_disabled as signup-disabled with specific copy

Review follow-up: GoTrue signals disabled email/password signups with
email_provider_disabled as well as signup_disabled; classify both (plus
the message-string fallback for older GoTrue) and give the register form
a specific inline message instead of the generic fallback.

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-06 21:24:36 +02:00
Mattsson a0ca692fed feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker

The gnubok_vat_close_check blocker hint only named categorize/auto-match,
both of which create new bookkeeping. For a transaction whose
affarshandelse is already booked on an existing verifikat, following the
hint would double-book, so agents dead-ended the case into "contact
support" (2026-08-06 support mail from Orto Engineering). The hint now
also names gnubok_link_transaction_to_journal_entry, is extracted as an
exported constant pinned by a test, and the tool joins the
categorize_month recommended loadout.

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

* feat(invoices): quarterly, half-yearly and yearly recurring schedules

User request: recurring invoice schedules only supported monthly cadence.
Adds interval_months (SMALLINT 1-12, default 1) to
recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/
halvarsvis/arsvis presets while API and MCP accept any 1-12.

The cron advances next_run_date by whole intervals from the due date, and
the new rollNextRunDateForward() helper rolls missed or edited interval
schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule
missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly
(interval 1) keeps its existing today-anchored recompute semantics
unchanged. Changing the interval alone never touches next_run_date: the
new cadence applies from the next run, so an edit can never pull a send
earlier.

Existing rows default to 1 and behave byte-identically. The MCP slice of
this feature (interval_months on the three recurring-schedule tools in
server.ts) was committed in d2600907f alongside the VAT-blocker hint fix
by a parallel session sharing this worktree.

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

* fix(invoices): address PR #1438 review findings

CodeRabbit round 1, all three findings:
- MCP descriptions now state the full accepted interval range (any integer
  1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that
  changing ONLY interval_months leaves next_run_date untouched.
- assertValidCadence rejects fractional day_of_month.
- rollNextRunDateForward rejects calendar-invalid anchors that pass the
  shape regex (2026-13-05, 2026-02-31), with regression tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:43:54 +02:00
Jakob Wennberg d41ef2a909 feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs

The sandbox showed neither Löner nor a usable set of reports, and the
"connect X" surfaces were oversized boxed cards.

Sandbox seed:
- pays_salaries + employer_registered, so Löner and Anställda appear at all
  (an enskild firma is not an employer by default). Both seeded employees are
  employment_type 'employee': an EF may employ staff, just not its own owner.
- Two employees, one booked and one open lönekörning, and the three verifikat
  the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/
  2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables.
- Year-to-date ledger history, January through last month, with the quarterly
  momsredovisning cleared to 2650 and paid on the SFL deadline. Without the
  settlement the demo collected VAT all year and never remitted it, which left
  an implausible bank balance and 155 813 kr of moms "att betala".
- The history is exempted through journal_entry_no_doc_required, the same way
  the SIE-import opt-in treats imported books: its kvitton live in the previous
  system, and unflagged it put 39 "verifikat utan underlag" on the home screen.
- Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma.
- History is numbered before the invoice and payroll vouchers so the series runs
  forwards through the year, and its writes are batched.

Connect CTAs:
- Bank picker: a two-column grid of 95px bordered logo cards becomes flat
  hairline rows, Lucide icons, and a quiet inline connecting state.
- Cloud backup: each provider collapses to one row; the BFL note is shown once
  for the section and names only configured destinations.
- Hem first-run: only the active step argues its case, but every not-done step
  keeps a reachable action. The Skatteverket nudge becomes one quiet sentence.

Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already
has an Assistent tab. A collapsed session keeps its handle everywhere except
/chat, which is itself the way back to the conversation.

Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox
guard, and a seeded booked run put "Skicka lönebesked" one click from an
anonymous visitor with live Resend behind it.

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

* fix(sandbox): check the two unchecked Supabase errors and tighten review nits

CodeRabbit review on #1437.

Major: two calls discarded their error and continued with null data. A failed
chart_of_accounts re-select would have written account_id: null onto every
ledger-history and salary voucher line, and a failed next_voucher_number would
have inserted a posted verifikat with no number, which is a hole in the
verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is
rejected explicitly.

Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the
salary breakdown test's name said the opposite of its assertions after the
switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat
per month before the quarterly momsredovisning added a seventh in March, May and
June.

Bank picker: the spinner is aria-hidden, so loading and connecting had no text
equivalent and a failed bank fetch was never announced. Added role="status" with
an sr-only label, and role="alert" on the error line.

Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was
unconfirmed before this PR too, so adding a dialog is a behaviour change beyond
the redesign rather than a fix to it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:04 +02:00
Jakob Wennberg 5b0ca3d874 fix(copy): make K3 and year-end claims match what the code actually does (#1431)
* fix(copy): make K3, leasing and year-end claims match what the code does

Follow-up to the batch that removed the uppskjuten-skatt posting on
obeskattade reserver (K3 29.37 gross in juridisk person) and added the K2
asset-account gate. Six user-facing strings still described the old
behaviour or made claims the code cannot support.

1. Arsredovisning page: the K3 explainer promised an uppskjuten skatt-not
   and a materiella anlaggningstillgangar-not in every K3 document. Both
   are conditional (a 2240/8940 balance, assets in the register) and the
   first is now absent in the normal case. The kassaflodesanalys is
   dropped with a warning when it cannot be generated, so it is named
   only when the document actually carries one.

2. Regelverk settings: kassaflodesanalys was presented as following from
   K3. It follows from being ett storre foretag
   (swedish-year-end-closing/references/reporting-and-filing.md:10,
   legal-framework.md:42); the copy now says the product includes one and
   states the storre-foretag rule separately. Komponentavskrivning was
   presented as optional under K3; it is mandatory where component useful
   lives differ materially (k2-vs-k3.md:5, asset-accounting
   references/depreciation.md:33).

3. Note 1 and the Uppskjutna skatter-not no longer claim the 2240 balance
   is hanforlig till obeskattade reserver. deriveLatentTaxMovement reads
   the 2240/8940 balances only, and under K3 that account carries deferred
   tax on all temporary differences (k2-vs-k3.md:11-13).

4. The deferredTax 'unknown' branch emitted the gross-reserve statement,
   which is the denial phrased positively: the same affirmative claim
   about books that could not be read. It now emits no deferred-tax
   paragraph at all; build-data already warns on that path.

5. Capitalized-lease detection looked at 1260/1269 only. On the shipped
   BAS 2026 chart 1260 is a free inventarier account and 1269 is ack.
   avskrivningar pa datorer, so owned computers were reported as leased,
   while 1217/1227 (finansiellt leasade) were missed. Detection now reads
   the company's own account names in kontogrupp 12, which is where BAS
   keeps capitalized leases (leasing-and-disposal.md:28) and which owned
   inventarier on 1220 never matches. 1720 forutbetalda leasingavgifter
   stays out: that is the operational treatment.

6a. gnubok_year_end_readiness listed FX revaluation as a blocker (it is a
   warning) and omitted UNBOOKED_TRANSACTIONS, the common one. The
   description now names every actionable blocker kind, within the
   280-char budget, and a test pins it against YEAR_END_BLOCKER_KIND.

6b. companies.accounting_framework defaults to 'k2', so every enskild
   firma hit the K2 asset gate and was handed a BFNAR 2016:10 punkt 10.4
   citation plus a K3 remedy it cannot take: a sole trader prepares ett
   forenklat arsbokslut, not an arsredovisning (legal-framework.md:29,
   :48). entity_type now rides along on the companies read the routes
   already do, and non-AB entities get wording with no citation and no
   K3, keeping the 1090 remedy. The K1 counterpart of punkt 10.4 is not
   sourced in the repo skills, so nothing was invented in its place.

* fix(copy): close the review findings on the copy-truth sweep

Three follow-ups from the source and code reviews. (1) The K2/K3 help text had upgraded a vague sentence into a definite boundary claim ('gransen gar vid <trosklar>'), which excludes the other routes into mandatory K3 that are live right now for this control's audience: noterade vardepapper, and from fiscal years starting after 2025-12-31 also utlandsk filial, kryptotillgangar, aktierelaterade ersattningar and fastighetsbolag. An AB in one of those categories would have read the sentence and stayed on a regelverk it may no longer use. (2) hasCapitalizedLeaseAsset compared per-side cumulative totals, so a lease acquired earlier and disposed this year still claimed the balance sheet carries a leased asset; it now compares the net balance. (3) The K3 warning enumerated a kassaflodesanalys the document may not contain, contradicting the newly conditional page copy on the same screen.

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-06 11:54:57 +02:00
Mattsson a5c10e441a chore(analytics): configure posthog session replay masking (#1428)
* chore(analytics): configure posthog session replay masking

Move session replay from the mask-everything default to pattern-based
masking in lib/analytics/replay-masking.ts: currency-shaped text,
person-/organisationsnummer (rendered and typed) and password inputs are
masked; other interface text and typed input is recorded for debugging.
data-ph-mask keeps force-masking tagged PII and data-ph-unmask is still
honored for chrome. Privacy policy, RoPA and decision log updated to
match.

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

* chore(analytics): address review comments on replay masking PR

Bump the privacy policy's visible last-updated date to 2026-08-06 and
add the conventional vi.clearAllMocks() beforeEach to the replay-masking
tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:40:54 +02:00
Jakob Wennberg ab712f829a fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark (#1423)
* fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark

The K3 redovisningsprinciper note asserted policies the engine does not
follow: full balansrakningsmetoden deferred tax and finance lease
capitalization. The note now states actual practice: deferred tax only on
obeskattade reserver at 20,6 percent, all leases expensed as operational.

Every K3 report is classified AR-K3-DRAFT-ONLY, so the K3 PDF now carries
a fixed rotated GRANSKNINGSUTKAST watermark on every page, and the
arsredovisning studio shows an always visible AttnLine for K3 explaining
that the document cannot be locked or filed via Accounted yet.

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

* fix(arsredovisning): stop the two K3 deferred-tax notes contradicting each other

The redovisningsprinciper note said deferred tax on obeskattade reserver is
not separately recognised in juridisk person, while the "Uppskjutna skatter"
note right after it disclosed exactly that split plus the 8940 movement. A
signed arsredovisning cannot say both.

Both notes are now driven by one derivation (deriveLatentTaxMovement in
build-data.ts, computed before note 1 is built): with no 2240/8940 activity
the policy paragraph states the gross treatment K3 29.37 permits and no
movement note is emitted; with a 2240 balance the paragraph discloses the
recognised liability and points at the movement note. Neither text claims a
measurement rate or an origin the builder cannot verify, since a balance can
come from the K3 bokslutsdisposition, a legacy posting or an SIE import
booked at 22 or 21,4 percent.

The leasing paragraph now states the basis for its blanket treatment: the
document is always a single juridisk person with no koncernredovisning, so
the K3 punkt 20.29 exemption applies. Without that qualifier the paragraph
asserted a treatment that is not the general K3 rule.

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

* fix(arsredovisning): never assert a K3 policy the books can contradict

Three cases where the principles note still made claims it could not back: a read failure on the 2240/8940 figures printed the affirmative denial anyway, the leasing paragraph asserted a fact about the entity's group obligations that a Koncernforhallanden note in the same document can contradict, and the blanket operational-lease claim survived even when the balance sheet carries leased assets on 1260/1269. Deferred tax becomes a tri-state (none/recognized/unknown) and leasing is driven by the same trial balance.

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-06 10:05:33 +02:00
Jakob Wennberg 12c59399ee fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion

In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings.

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

* docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance

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

* fix(settings): stop promising deferred-tax accounting the engine no longer does

The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37.

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-06 10:05:15 +02:00
Jakob Wennberg 8f38baca05 fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults

K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally
developed intangibles, but the asset register defaulted the immaterial
category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no
framework gate beyond K3_REQUIRED_FOR_COMPONENTS.

- New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH
  /api/assets/[id]: when accounting_framework is not k3, reject any asset
  whose resolved asset or accumulated account is flagged k2_excluded in
  the BAS reference. Resolution mirrors the service defaults so category
  defaults cannot sneak onto 1010/1019; patches that leave category and
  accounts untouched skip the gate so legacy assets stay editable.
- Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code
  registered in structured-errors.ts with Swedish and English messages.
- CreateAssetDialog: non K3 companies now book immaterial assets on the
  purchased pair 1090/1099 with a quiet hint that egenupparbetad
  utveckling requires K3; K3 companies picking immaterial see a note
  about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par.
- Route tests: K2 rejected on 1010 defaults and explicit overrides, K2
  accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents
  and a gate skip regression test.

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

* fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate

The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the
rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4
citation. The flag also covers accounts excluded from K2 for unrelated reasons
(1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940,
8290 to 8480), so those users got a factually wrong legal citation in a
compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS
range refinement, so an explicit bas_asset_account override outside the
category range hits the gate before updateAsset() raises its range error.

- k2ExcludedAccountMessages() now picks the wording from what actually
  triggered the gate. The boundary is derived from the chart itself
  (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the
  egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a
  flag change in bas-data moves the boundary with it. Other Ej K2 accounts get
  a generic message: the chart marks it Ej K2 and it requires K3, with no
  invented paragraph reference.
- Both messages are bilingual (message_sv / message_en, registry shape) and
  the routes now return message_en alongside message.
- The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation
  too: it is the code level fallback for every k2_excluded account.
- Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4,
  1370 must not), plus a guard unit test asserting the derived group and that
  no non group 10 Ej K2 account ever cites 10.4.

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

* fix(assets): let K2 companies register acquired intangibles, server side

The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE
immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24,
"Only acquired intangibles may be recognized"). But asset-service still
resolved category 'immaterial' to 1010/1019 for everyone, and only
CreateAssetDialog compensated with an explicit 1090/1099 override.
EditAssetDialog sends just the changed fields and has no account inputs, so a
K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit
the defaults, got a 422, and was told to switch the company to K3, which
would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole
arsredovisning. The asset stayed on 1220/1229 and kept being presented as a
tangible asset.

- defaultAccountsForCategory(category, framework) is the single resolution
  point: immaterial resolves to the acquired pair 1090/1099 unless the
  framework is k3, every other category is unchanged. Both createAsset() and
  updateAsset()'s category realign go through resolveDefaultAccounts(), which
  reads companies.accounting_framework only for the intangible category and
  throws rather than guessing when that read fails. Explicit overrides and the
  realign-skip semantics are untouched.
- Both routes resolve gate accounts through the same function, so the check
  mirrors what the service will persist. A K2 company on the defaults now
  passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s.
- CreateAssetDialog drops its now redundant client override so the two
  surfaces cannot drift; the hint text stays.
- The 422 no longer asserts the company's framework (the companies read
  behind it discards its error, so a transient failure would assert it against
  a K3 company) and no longer proposes a regelverk change. It states that the
  account is reserved for egenupparbetade utvecklingsutgifter, which require
  K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped
  to the kontogrupp 10 group, derived from the chart as before. sv and en.

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-06 10:04:58 +02:00
Jakob Wennberg 78a37f6396 fix(year-end): typed preflight blocker codes so remediation links render (#1420)
* fix(year-end): typed preflight blocker codes so remediation links render

validateYearEndReadiness emits Swedish blocker strings but the wizard's
BlockerRow matched English phrases, so no remediation link ever rendered,
and the voucher-gap branch pointed at /bookkeeping/voucher-gaps which only
exists as an API route. Blockers now carry stable machine codes end to end
(YearEndBlockerCode on YearEndValidation.blockers, mirrored additively as
blockerItems on BokslutReadinessReport); errors stays the plain string
mirror so the v1 compliance check and MCP tool keep their exact shapes.
BlockerRow matches on code and links only to pages that exist; the
voucher-gap and dead-link branches are removed.

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

* fix(year-end): code the unbooked-transaction blockers #1414 added

#1414 landed two new blockers in validateYearEndReadiness using the old
errors.push style, which this branch had already renamed to a typed
blockers array. Merging main left them referencing a variable that no
longer exists.

Converted both to the typed scheme: UNBOOKED_TRANSACTIONS (the safety
guard that stops executeYearEndClosing from aborting at the step 7 lock
AFTER the closing entry posted at step 4) and UNBOOKED_CHECK_FAILED (the
fail-closed variant). Neither behaviour changes; both keep their Swedish
wording verbatim.

The MCP year_end_readiness classifier now routes on the stable
YearEndBlockerCode instead of regexing the Swedish message, with the
wording heuristic kept as a fallback for an unmapped or legacy English
message. The public `kind` values are unchanged, so MCP consumers see the
same output; both new codes map to 'unbooked_transactions' as before,
since an agent reacts to "we could not tell" the same way it reacts to a
real count.

UNBOOKED_TRANSACTIONS gets a /transactions remediation link in the
preflight step: that page is where a transaction is booked or marked
private, the two remedies the message names. UNBOOKED_CHECK_FAILED gets
none: the remedy is to re-run the check.

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:42 +02:00
Jakob Wennberg d7320a6c87 feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices (#1426)
* feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices

A kassakvitto has no due date, no OCR reference, no invoice number and no
bankgiro: it is already paid. The rail rendered all four as empty boxes
anyway, so a perfectly extracted receipt looked like a failed extraction.
The date was mislabelled too: on a receipt the document date is the
purchase date, not a "Fakturadatum".

When extracted_data.documentKind is 'receipt' the rail now labels the date
"Inköpsdatum" and folds the five invoice-only fields behind a quiet "Visa
fakturafält (N)" link.

Two rules keep it safe when the classification is wrong: a field holding a
value is never hidden (a hybrid restaurangnota with an invoice number
still shows it), and the fold is one click from being undone.

Logic lives in lib/documents/inbox-field-visibility.ts rather than the
component: this repo has no jsdom or testing-library, so display rules are
only testable as a React-free module.

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

* fix(invoice-inbox): reset the invoice-field fold when switching documents

Review catch (PR Agent on #1426): showAllFields was component state with no
per-item reset, and EditableFieldsList stays mounted across selections in
the rail. Expanding "Visa fakturafält" on one receipt therefore left the
invoice-only fields open on the next document, which reads as if that one
carried them too.

Reset alongside drafts and edit provenance in the existing itemId effect,
and moved the declaration up next to the other state so it is defined
above its first use rather than relying on hoisting.

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-05 20:26:16 +02:00
Jakob Wennberg 0449b1d0ea feat(settings): WhatsApp brand mark on the WhatsApp settings page (#1418)
The page had no visual signal that it configures a third-party channel,
so it read like any other Accounted setting. Adds an inline-SVG WhatsApp
mark next to the section title and on the button that opens WhatsApp
(replacing the generic lucide chat bubble, which was standing in for a
logo it is not).

Inline SVG rather than a bundled asset: no network request, scales, and
survives the CSP. SettingsSectionHeader gains an optional `mark` slot;
every other settings tab is untouched and stays mark-less on purpose, so
the rail does not turn into a sticker album. The green is the one place
brand colour appears in settings, which matches the design rule that
colour belongs to actors rather than chrome.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:30:13 +02:00
Mattsson f633349c8d feat(analytics): show form field labels in session replays (#1416)
* feat(analytics): show form field labels in session replays

Replays showed the sidebar after #1412 but form pages were still fully
masked, so you could not tell WHICH field a user was interacting with.
Tag the shared Label primitive (components/ui/label.tsx, used by every
form in the app) with data-ph-unmask: field labels are static i18n
chrome, and maskAllInputs keeps every typed value hidden.

The one Label whose text is user data, the user-defined dimension name
in LineDimensionFields, gets data-ph-mask, which wins even on the same
element because maskTextFn checks it first.

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

* fix(analytics): re-mask three Labels that render user data

Completing the audit the PR review asked for: a multiline sweep over
every Label child found three call sites whose label text is user
data, missed by the first single-line pass. Danger-zone confirm
labels interpolate the user's email (AccountDangerZone) and the
company name (CompanyDangerZone), and the periodisering auto-detect
row label is counterparty name + invoice number. All three now carry
data-ph-mask. Currency-code and row-count interpolations were
reviewed and left visible: categorical UI state, not books data.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:18:07 +02:00
Jakob Wennberg d1c411ad6f feat(invoice-inbox): surface WhatsApp chat context in booking flows (#1339)
* feat(invoice-inbox): surface WhatsApp chat context in booking flows

The WhatsApp intake bot writes verified human answers (photo caption,
representation deltagare + syfte, sender note, open-question state) to
invoice_inbox_items.channel_context. This makes the in-app booking flows
READ it:

- New core renderer lib/documents/channel-context-notes.ts: deterministic
  compact Swedish line ("Representation: Anna Berg (Volvo), Jakob W ·
  Syfte: uppföljning av avtal"), capped at 220 chars by dropping whole
  participant names ("… och N till"), never mid-name. Representation
  first, then user_note; caption only when nothing else exists.
- FieldsRail "Från WhatsApp" block in InvoiceInboxWorkspace: caption,
  deltagare, syfte, anteckning rows plus an ochre AttnLine when a chat
  question expired unanswered (pending_question.status = moved_to_app).
- Notes threading: book-direct and convert default their notes to the
  rendered string server-side when the request carries none (a supplied
  value always wins); BookDirectlyDialog prefills its notes input with
  the same string so the user can edit it before it lands. Bulk-book
  (categorize-core) joins the shared batch note with the per-item
  rendered context so the representation trail survives batch booking.
- Inbox list: whatsapp rows get a chat icon and a quiet "Fråga obesvarad"
  badge for moved_to_app items. No worklist count change: unresolved
  whatsapp items are already counted by countInboxDocuments.
- sv/en strings for every new key; renderer + route + bulk tests.

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

* fix(invoice-inbox): honor cleared notes and keep unreviewed captions out of verifikat

Two adversarial-review findings on the WhatsApp surfacing flows, both about
text that lands on an immutable verifikat.

1. A cleared note was silently re-applied. BookDirectlyDialog prefills the
   rendered chat context, and the dialog sent `notes.trim() || undefined`
   while book-direct and convert defaulted from channel_context on any falsy
   value. A user who read the prefill, disagreed and deleted it therefore got
   it written back onto a posted entry, removable only through a formal
   rättelse. Both code comments claimed "an edited value always wins", which
   was false for exactly that edit. Now PRESENCE of the field decides: the
   dialog always submits `notes` (empty string included) and the routes only
   default when the field is absent from the request (MCP, older clients).
   The Zod `.optional()` carrying that distinction is documented at the
   schema so it is not "tidied" into a `.default('')` later.

2. The photo caption was auto-burned into verifikat text with no review.
   renderChannelContextNotes fell back to the raw caption, and bulk-book
   appended the result per item with no per-item notes field at all (the MCP
   approval preview deliberately shows no per-item PII either), so unreviewed
   chat text reached a WORM record nobody had seen. The renderer now takes
   { includeCaption } and leaves the caption out by DEFAULT: representation
   answers and user_note are replies to a question the bot asked, the caption
   is not. Only the Bokför direkt prefill opts in, where the user reads the
   string in an editable field before booking.

Tests: cleared-notes and whitespace-cleared on book-direct, cleared-notes on
convert, caption-never-defaulted on both routes, caption-not-threaded in
bulk-book, and the renderer's opt-in. The book-direct cleared-notes tests
were mutation-checked (restoring the truthiness fallback fails them).

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

* fix(reports): export the chat answers behind a verifikat in the full archive

The verifikat line caps the representation trail at 220 chars and drops whole
participant names ("… och N till"); the complete list (deltagare, syfte,
raw_answer) exists only in invoice_inbox_items.channel_context. That table was
in ARCHIVE_EXCLUDED_TABLES with a rationale predating channel_context ("inbox
workflow state"), so a company leaving Accounted and keeping the full-archive
export as its BFL 7-year record kept an incomplete deltagare documentation for
its representation deductions.

Dumped as a column PROJECTION, not the whole row: the new
MasterDataTableSpec.columns narrows the select to the underlag provenance
(document, matched transaction, created verifikat / leverantörsfaktura) plus
channel_context, so the answers are tied to what was booked from them while
the inbox workflow state (email bodies, OCR output, error messages) stays out
of the archive. The documents themselves remain in dokument/.

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:54:30 +02:00
Jakob Wennberg 398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:47:08 +02:00
Jakob Wennberg 3b3adf96c7 feat(invoice-inbox): receipt-aware extraction + OCR pipeline fixes (#1331)
Receipts and invoices were extracted through one invoice-shaped prompt
with no document classification. The extractor now also returns
documentKind, payment method (+ card last4), purchaseTime,
merchantCategory and legibility, validated with .catch(null) so a
hallucinated label degrades to unknown instead of sinking the parse.
The FieldsRail shows type and payment method above the editable fields.

Pipeline fixes, all verified against real failure paths:
- PDFs >3 pages: extract from a pdf-lib slice of the first 3 pages
  instead of skipping entirely (issue #553 gate); truncation recorded
  in extracted_data.pages and shown in the UI.
- Oversized images (>4 MB, over Bedrock's 5 MB cap): downscale to
  <=2000px JPEG via sharp before base64, instead of erroring to an
  empty result.
- HEIC/HEIF: attempt sharp transcode to JPEG; when libvips lacks HEIF
  (prebuilt binaries), fall through to today's behavior but show an
  explicit hint instead of silently blank fields.
- Oresavrundning: prompt rule + totals.roundingAmount so receipt totals
  reconcile with subtotal+VAT for exact-amount transaction matching.
- retry-extraction overwrites extracted_data wholesale: a confirm now
  guards against silently destroying manual field edits.

Deliberately NOT added: retry-on-transient-Bedrock-error; the SDK
already retries twice by default (maxRetries=2).

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:20:40 +02:00
Mattsson b1f71912a2 feat(analytics): show static nav chrome in session replays (#1412)
* feat(analytics): show static nav chrome in session replays

Replays previously masked every text node via maskTextSelector '*',
which made them unreadable: even sidebar labels and buttons were
asterisks. Add a fail-safe maskTextFn: text stays masked unless its
nearest tagged ancestor is data-ph-unmask, and data-ph-mask re-masks
user data nested inside an unmasked container. Untagged text stays
masked, so a forgotten tag can never leak user data.

Tags the four nav containers and the skip link; the notification
count bubbles inside them are re-masked. User menu, company switcher
and page titles stay masked on purpose: on detail pages the title is
user data.

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

* docs(analytics): use English domain terms in masking comment

CodeRabbit: comment used 'enskild firma' and 'personnummer'; repo
guideline is English for all comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:19:36 +02:00
Jakob Wennberg 9f5a43310b fix(salary): recompute entitled_days on existing ledger rows and record pre-cutover taken days (#1403)
The vacation ledger sync carried entitled_days verbatim on existing open
rows while re-deriving accrued and taken, so a stale entitled value (for
example the flat 25 stored before Semesterlagen 7 § pro-rating existed)
survived every sync. The recompute loop now re-derives entitled the same
way the lazy-seed path does, with the opening-balance cutover still
outranking recomputation for the year containing cutover_date.

Opening balances could also not record paid vacation days already taken
in the cutover year under the previous payroll system. New additive
column employee_opening_balances.vacation_days_taken_this_year (NUMERIC
NOT NULL DEFAULT 0, CHECK 0..40) threaded through the shared service,
the Zod schema, the MCP staging tool (schema + mergeable fields), the
staged-operation executor, the v1 REST routes, and the employee editor
form. Ledger semantics for the cutover year, on both seed and recompute
paths: entitled = remaining + taken_this_year, taken = booked-run taken
+ taken_this_year, so remaining keeps meaning remaining and the seeded
value survives every subsequent sync.

Fixes #1347

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:35:02 +02:00
Mattsson 5ca64bde30 feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

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

* chore(db): move tax depreciation migrations to coordinated versions

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

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

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

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

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

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:43:58 +02:00
Mattsson 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

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

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

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

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00
Mattsson 1c9d378df8 feat(auth): enforce session idle and absolute timeouts (#1387)
* feat(auth): enforce session idle and absolute timeouts

Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding
session start, last activity and sign-in method, bound to the Supabase
session. Middleware enforces a 30 min idle and 12 h absolute limit
(reason-coded redirects to /login), a heartbeat route advances idle
activity from real user input, and a client controller warns 2 minutes
before expiry. BankID users are routed back to BankID on re-auth via a
short-lived method hint. API-key and MCP bearer surfaces are exempt;
self-hosted installs default off and can opt in via env vars.

Fixes #362

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

* fix(auth): derive session-timeout signing key via HKDF

The HMAC key is now HKDF-derived with a purpose-bound info string, so
the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged
credential directly as a signing key. Addresses the security review
finding on PR #1387.

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

* fix(auth): back signature bytes with a plain ArrayBuffer

crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed
over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode
base64url into a Uint8Array constructed over a fresh ArrayBuffer.

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

* fix(auth): address session-timeout review findings

- signSessionTimeoutState returns null on signing failure instead of
  throwing, so a missing secret degrades the timeout feature in line
  with verifySessionTimeoutState rather than crashing authenticated
  requests; middleware and heartbeat skip the cookie write when null
- heartbeat initializes a fresh signed state for a missing or
  session-mismatched cookie, mirroring middleware, instead of
  returning SESSION_EXPIRED during normal initialization
- sessionStateMatchesUser treats an unresolved current session id as
  a mismatch for session-bound state so another session's cookie is
  never accepted on the userId fallback alone
- drop aria-live from the countdown DialogDescription so screen
  readers are not interrupted every second

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:59:42 +02:00