Commit Graph

1092 Commits

Author SHA1 Message Date
Jakob Wennberg fb3feaea12 refactor(import): de-box the migration wizard and give it a real ending (#1617)
The provider-migration wizard was a pile of cards in cards: a boxed
progress rail with a fat bar, a bordered Card per step with more bordered
boxes nested inside, amber warning panels, and a result step made of a
verdict card, orange-badged per-year cards and a green-check card grid.
All of it against the design conventions (no boxes doing hierarchy's job,
attention is one ochre sentence, status colors are data not chrome).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* fix(mcp): address review findings on account_override

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* fix(invoices): harden Peppol compliance guards

* fix(api): narrow Peppol document loading

* test(pg): hash Peppol fixture payload

* fix(invoices): address Peppol review findings

* test(pg): isolate Peppol provider events

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

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

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

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

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

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

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

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

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

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

* chore: re-trigger CI after Vercel infra hang

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

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

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

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

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

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

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

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

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

---------

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:01:15 +02:00
Mattsson 857dd575d0 fix: harden kontantmetod year-end cutoff (#1592) 2026-08-13 16:53:19 +02:00
Mattsson a47ba9fede fix(enable-banking): stop renewal history floods (gap-fill default + backfill reconciliation) (#1590)
* chore(ci): guard bedrock-sdk against automated version bumps

The 2026-07 prod outage (empty Bedrock streams breaking invoice OCR and
the assistant) came from an unreviewed @anthropic-ai/bedrock-sdk 0.32.0
bump. The package is exact-pinned to 0.29.1, but nothing stopped an
automated PR from proposing the bump again. Add a dependabot config in
security-updates-only posture (open-pull-requests-limit: 0) with an
ignore for bedrock-sdk >=0.30.0 so neither scheduled nor security
updates can reintroduce it silently.

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

* fix(enable-banking): stop renewal history floods (gap-fill default + backfill reconciliation)

Renewing a bank connection walks the same pending_selection -> active
flow as a first connect, and a fresh consent often makes the bank
release history the first connect never delivered. Two gaps turned that
into a flood of falsely 'unhandled' rows over already-bookkept periods
(11 companies, ~600 rows in prod):

- The picker defaulted every renewal to the fiscal-year lookback. It now
  probes the connection's newest imported transaction and defaults a
  renewal to 'continue where the last fetch stopped' (7-day overlap,
  absorbed by external_id dedup), with an .attn warning when a longer
  lookback re-requests already-fetched periods.
- The inline initial backfill ran without the SIE-overlap guard that the
  manual /sync route and the cron both apply. It now suppresses
  auto-categorization on overlap and runs the same unattended-threshold
  reconciliation sweep, scoped per ledger account via
  resolveCashAccountScope instead of the pooled unscoped form
  (#1290/#1298), and surfaces the linked count as auto_matched in the
  sync summary.

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

* fix(enable-banking): renewal guard survives mid-open refetch; harden sweep scope and window

Skeptic pass findings on the renewal-flood guard:

- REFUTED: the settings panel's visibility refetch (near-certain in a
  BankID reconnect) hands the open picker a fresh accounts identity; the
  pre-existing reset effect then wiped the gap-fill state while the
  probe effect never re-ran, silently stranding the renewal back on the
  fiscal-year default with no warning. The probe now keys its state by
  connectionId and shares the reset's triggers via an accounts dep, so
  wipe and re-probe always pair up.
- The sweep skips accounts whose cash_accounts row did not resolve
  (found: false) instead of degrading to the pooled currency-only form
  (#1290 write shape), which could otherwise follow a same-request
  mirror-upsert failure.
- The sweep window opens at the oldest booking date the bank actually
  returned: over-returning ASPSPs ingest rows outside the requested
  window, which the sweep would otherwise never examine.
- resolveGapFillStart clamps to the backend's 365-day lookback floor so
  the radio never promises a start date the backfill cannot honor.

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

* fix(enable-banking): address PR #1590 review findings

- zizmor: add a 7-day cooldown to the dependabot npm entry.
- CodeRabbit: clear the event bus in the accounts-route beforeEach (repo
  test convention); surface probe query failures in AccountPickerDialog
  so a failed probe cannot read as a first connect and silently restore
  the fiscal-year default; build the sweep's ledger-account list with a
  string filter instead of a nullish fallback so the pooled scope path
  is structurally unreachable.
- Swedish compliance review: document at the sweep site that linking
  writes bank-feed metadata only, never journal tables, with the
  opening-balance link trigger and unlinkReconciliation reversibility
  spelled out.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:50:24 +02:00
Mattsson 9ad3908ed0 fix(salary): skatteavdrag rounding trio (whole kronor, ,50 table pick, import prefix guard) (#1582)
* fix(salary): state percentage skatteavdrag in whole kronor (SFF 22 kap. 1 §)

calculateJamkningTax and calculateSidoinkomstTax returned öre-precision
amounts; skatteavdrag is stated in whole kronor with öretal dropped
(SFF 2011:1261 22 kap. 1 §), the same rule taxForRate already applies to
percent brackets. The two inline flat-30% branches in calculation-engine.ts
(unverified F-skatt, no-table fallback) had the same defect and now route
through calculateSidoinkomstTax.

Computed in integer öre and hundredths of a percent: flooring the raw float
product loses a whole krona when float noise lands an exact result just below
an integer (1000 * 0.007 === 6.999999999999999).

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

* fix(salary): pick the lower tax table at exactly ,50 per Skatteverket rule

Math.round sent a total municipal rate of 32,50 to table 33; Skatteverket's
rule is that a fractional part of at most 50 öre picks the lower table and
51 öre or more the higher. Compared in hundredths so float noise cannot
decide the boundary. Latent today (no kommun sits exactly on ,50 for 2026)
but the code now matches the comment above it, which already stated the
correct rule.

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

* fix(salary): reject two-week rows in the monthly tax table import

parseLine only checked position 3 for B/%, so a two-week table row (14B29)
would silently merge into the monthly fallback data if the wrong Skatteverket
file were used as input. The day-count prefix must now be 30; a 14-row throws
loudly. main() is guarded behind a direct-execution check (same pattern as
generate-crontabs.ts) so parseLine is importable by tests.

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

* fix(salary): truncate toward zero, not floor, in whole-krona skatteavdrag

Skeptic refutation: taxable income can go negative when deductions exceed
pay, and Math.floor rounds negatives away from zero, so a payslip 1 öre
negative would book a full krona of negative withholding
(calculateSidoinkomstTax(-0.01) gave -1 instead of -0). Öretal bortfaller
truncates toward zero: Math.trunc, with -0 normalized to 0.

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

---------

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

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

Now there is one owner:

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:17:15 +02:00
Mattsson 22eaab82e5 feat(invoices): add Peppol XML export foundation (#1585) 2026-08-13 16:17:00 +02:00
Jakob Wennberg 6c64dd2312 fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563)
* fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552)

Silence was a legitimate outcome in seven places and none left a trace a
support question could be answered from. Now:

- Unknown-sender declines (over quota, quota RPC failure, greeting
  throttle) persist content-free trace rows: wamid, phone hash, type,
  disposition. No body, media, raw payload, or profile name; capped at
  20 rows per hash and day; deleted by the existing 30-day retention.
  The wamid dedupe also stops redelivered bad-code/greeting messages
  from earning a second reply.
- Linked-sender deliberate silences (muted, stale tap, ignorable type)
  record their reason on the skipped row.
- Non-policy silences reply: a row missing its media reference sends
  M18 through the link's reply address, a link revoked between arrival
  and processing sends the M1 unlinked copy (greeting-throttled).
- Outbound rows keep WHY a send failed (Graph error detail), and Meta
  'failed' delivery statuses store their error code and title.
- The WhatsApp settings panel shows the last inbound event (closed
  enum, server-derived) and warns when the latest reply never left.

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

* test(whatsapp): include errorDetail in typed sendText mock results

SendTextResult gained errorDetail; vi.mocked call sites must match the
widened type or they raise fresh tsc errors over the repo baseline.

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

* fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces

From CodeRabbit's pass on #1563:
- greetingThrottled fails closed when the throttle window cannot be
  read, matching the unknown-sender quota's stance.
- The decline-trace day cap applies only to 'skipped' rows (the one
  unbounded path); 'done' traces always insert so the wamid dedupe
  keeps preventing duplicate M1/M2 replies even past the cap. Their
  volume is already bounded upstream by the greeting throttle and the
  pre-binding quota.
- company-question test mocks match the widened SendTextResult.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:02:16 +02:00
Mattsson 73c63209f1 feat: stage kontantmetod year-end cutoff (#1586)
* feat: stage kontantmetod year-end cutoff

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
2026-08-13 15:55:01 +02:00
Mattsson 9be20b4274 test(bookkeeping): use hotel account in VAT fixture (#1587) 2026-08-13 15:54:31 +02:00
Mattsson 3829b6add3 fix(ai): complete plain-key self-hosting path (#1584)
* feat(ai): resolve the Claude backend from the environment

Tier 1 of #1406: a self-hosted deployment can now run every AI feature on a
plain ANTHROPIC_API_KEY, with no AWS account. Hosted behaviour is unchanged.

lib/ai/provider.ts resolves the backend once, from the environment:

  AI_PROVIDER              explicit override, bedrock|anthropic
  AWS static key pair      Bedrock
  ANTHROPIC_API_KEY        the direct Anthropic API
  nothing set              Bedrock, so the AWS credential provider chain
                           (instance profile, IRSA) still resolves

Bedrock deliberately wins when both credential sets are present. EU residency
in eu-north-1 is a BFL/GDPR posture rather than a default, so adding an
Anthropic key for an experiment must not silently move production inference
out of the region. AI_PROVIDER is the way to say you meant it.

Model ids are written bare in code and prefixed to eu.anthropic.* only for
Bedrock, which needs the cross-region inference profile for on-demand
throughput. An operator override that already carries a prefix passes through
untouched, so BEDROCK_MODEL_ID and friends keep working as written.

Converted call sites: the agent composer, invoice-inbox extraction, the
document-extraction model label, and both receipt-hunt clients. The last two
are not named in the issue, which predates receipt-hunt landing in main.

@anthropic-ai/sdk is declared at 0.95.0, the version @anthropic-ai/bedrock-sdk
0.29.1 already pulled in transitively, so the lockfile dedupes to one copy
with no new download.

scripts/smoke-bedrock.ts becomes scripts/smoke-ai.ts and grows two steps.
Unit tests can only prove which provider and model id get resolved; they
cannot prove the resulting request is one the backend accepts. The script now
sends real traffic over all three shapes the app uses: a plain create, a
streamed turn carrying adaptive thinking, an effort level, an hour-long cache
breakpoint and a tool, and document extraction end to end when given a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* docs(self-hosting): document the AI smoke test

The script added alongside the provider split is what closes the #1406
acceptance criterion ("document extraction and the assistant both work"), so
a self-hoster needs to know it exists. Covers both invocations and states
that it exits non-zero, which is what makes it usable as a post-deploy check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* test(ai): split the smoke test's thinking probe from its tool probe

The combined probe could not falsify what it claimed to. It asked a question
that needs a tool call, so the tool was used and adaptive thinking correctly
declined to reason about it: the zero thinking-block count that came back was
uninformative rather than a signal.

2a keeps the tool and drops thinking. 2b asks a question with several
dependent steps (reverse charge, then a partial deduction, then the affected
boxes) so that a model honouring the parameter must reason, and reports the
thinking text length as well as the block count, since display:"summarized"
can yield blocks with empty text.

The cached system prompt is also padded past the 1024-token minimum cacheable
prefix. Below that the API caches nothing and reports no error, so the old
probe's cache counters read zero whether or not caching worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* fix(document-extraction): stop requiring AWS_REGION in the manifest

The extension now needs one of two credential sets, AWS static keys or
ANTHROPIC_API_KEY, and the manifest schema cannot express "one of". Since
requiredEnvVars only drives a build-time warning and never gates anything,
listing AWS_REGION told every self-hoster running the direct API to set a
variable that has no effect for them.

The description was also still promising Sonnet 4.6 via Bedrock specifically,
which is no longer what the extension does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* fix(ai): read documentKind defensively in the smoke test

The field arrived with the receipt-aware extraction work, so referencing it
directly stops the script compiling against any checkout from before that
landed. tsconfig includes **/*.ts and next.config does not disable type
checking, so on such a checkout this failed the production build rather than
just the script: caught while preparing a test branch for a self-hosted
instance that had not synced yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* fix(deps): restore the nested @swc/helpers entry in the lockfile

Declaring @anthropic-ai/sdk with `npm install --package-lock-only` also pruned
node_modules/next-intl/node_modules/@swc/helpers@0.5.23, an optional peer entry
the local npm 11 considers redundant and the image's npm 10.9.8 does not. The
result passed every local check and failed `npm ci` inside the Docker build,
which is the only place the lockfile is actually enforced.

The lockfile is now the previous one plus the single root dependency line,
verified with `npm ci --dry-run`. @anthropic-ai/sdk needed nothing else: it was
already in the tree as a transitive dependency of @anthropic-ai/bedrock-sdk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>

* Update DECISIONS.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update Docker documentation for AI provider credentials

Clarify the role of credentials in AI provider selection and document extraction requirements.

* Update SELF-HOSTING.md with smoke-ai script details

Clarify usage of smoke-ai script for credential checks and document extraction.

* Improve error handling and logging in smoke-ai script

* fix(ai): complete plain-key self-hosting path

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

---------

Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-13 15:45:24 +02:00
Mattsson 0c3864cae5 fix(payroll): normalize KU10 organisation numbers (#1583)
Normalize KU10 employer identities to Skatteverket's 12-digit schema format, validate the structural XSD contract, correct FK201's XML element name, and add focused sourced tests. Resolves #1410.
2026-08-13 15:39:49 +02:00
Jakob Wennberg a82f126031 docs(bookkeeping): audit + runbook for template-caused mis-bookings (#1398)
* docs(bookkeeping): audit + runbook for template-caused mis-bookings

Two of the template defects fixed this week produced postings that SUCCEEDED
and are still sitting in customers' huvudbocker: travel_hotel debited 5820
Hyrbilskostnader instead of 5830 Kost och logi (#1397), and the representation
template deducted 25% input VAT on a 12% restaurang supply (#1396). Fixing a
template only changes future postings.

Follows the pattern already established by SETTLEMENT_ACCOUNT_REMEDIATION.md
for the same class of problem: read-only detection, per-entry evidence review,
staged storno with explicit approval, no automated bulk mutation.

Deliberately excludes vehicle_parking (5614) and it_cloud_hosting (5421). Those
named accounts that never existed in BAS, so account-backfill could not seed
them and every booking failed. Nothing was posted, nothing to remediate.

Detection is by account signature and is diagnostic only, because there is no
provenance link from a posted entry back to the template that produced it:
template_id lives on mapping_rules, not on journal entries. Both signatures
have legitimate shapes (5820 IS correct for real car hire; representation at
25% IS lawful when the supplier charged 25%), so a row is a question and never
a verdict.

The classifier is verified against seeded probes rather than assumed: a hotel
booked to 5820 with a hotel counterparty ranks high, a genuine car hire on 5820
falls to manual review, a 25% representation ranks high, and a correct 12%
representation does not appear at all. Query confirmed to run against the real
schema (the lock date lives on company_settings, not companies).

The runbook records what BFL 5 kap 5 § actually requires: both tracks, that
storno is the only one available once a period is locked or the bookkeeping has
been relied upon, and that there is NO numeric materiality threshold in BFL.
Materiality decides whether a historical correction is worth making, never
whether a silent one is allowed. For the VAT defect it also flags that a filed
momsdeklaration makes this an omprovning question, not just a ledger one.

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

* docs(bookkeeping): harden template misbooking audit

* fix(bookkeeping): retain mixed voucher audit candidates

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-13 15:30:20 +02:00
Jakob Wennberg 7ae3477b36 fix(mcp): page list tools past PostgREST's silent 1000-row cap (#1572)
gnubok_list_accounts returned exactly 1000 rows for a full BAS 2026 chart
(1290 accounts) with no truncation signal: PostgREST caps un-ranged selects
at 1000. Wrap the query in fetchAllRows, paging on the unique account_number
and re-sorting by sort_order in JS so the visible order is unchanged. Same
fix for gnubok_list_customers, gnubok_list_suppliers and gnubok_list_articles
(paged on id, re-sorted by name), the Accounted://chart-of-accounts resource,
and the REST v1 accounts.list route. All output schemas and registry metadata
unchanged.

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:30:12 +02:00
Mattsson 36393b1f8d fix(mcp): correct e-invoice capability guidance (#1580)
Closes #1577. Native Peppol and EN 16931 support remains tracked in #546.
2026-08-13 15:29:32 +02:00
Jakob Wennberg 9c891ee72d fix(import): SEB CSV imports survive BOMs and bad format choices (#1565)
* fix(import): handle BOMs at the byte level in decodeFileContent

Inspect leading bytes before decoding: EF BB BF strips the UTF-8 BOM and
decodes the remainder (falling back to Windows-1252 for the remainder only,
so the fallback can no longer produce a literal mojibake prefix), and
FF FE / FE FF decode as UTF-16LE/BE. stripBOM additionally strips a literal
mojibake BOM prefix for string paths pre-decoded elsewhere.

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

* fix(import): make an explicit SEB choice at least as good as auto-detect

Three changes for the SEB bank CSV report:

- parseBankFile: when an explicit format parses 0 transactions, fall back
  to auto-detection; a different format that parses rows is returned with a
  prepended info issue naming both formats. A working explicit parse is
  never overridden, and explicit generic_csv (the manual mapping escape
  hatch) is exempt.
- SEB profile: sniff the header delimiter (';' vs ',') and split with the
  quote-aware parseCSVLine; accept a bare Datum date column as a lowest
  priority tier in parse only, never in detect. Its user-reachable issue
  strings are now Swedish.
- Import page: when a parse yields 0 transactions, show the parser's real
  issues instead of only the generic no-transactions hint.

The v1 agent route now decodes through the shared decodeFileContent and
stamps external ids, import_source, and the stored file format from the
format the parse result actually carries, so fallback imports dedup
identically to auto-detected ones.

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

* docs(api-v1): the bank import route also decodes UTF-16

CodeRabbit on #1565: decodeFileContent gained UTF-16LE/BE BOM support
but the route overview and the registered endpoint description still
listed only UTF-8 / Windows-1252. Skill regenerated (apiskill:generate).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:22:19 +02:00
Jakob Wennberg 0d3ba5268d fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3

The booking-time duplicate guard missed the most common bank-fee twin
shapes:

- G1: the sibling scan matched on the EXACT date only, so a duplicate
  import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was
  invisible. The scan now uses a +-3 day window with a deterministic
  ranking where exact-date candidates always outrank drifted ones
  (force=true re-detection stays bound to the reviewed candidate).
- G2: booked-ness required transactions.journal_entry_id, so bulk-booked
  (transaction_voucher_links) and multi-allocated (invoice_payments /
  supplier_invoice_payments) siblings read as unbooked. The scan now
  batch-fetches the anchor rows and resolves the verifikat via
  getPrimaryJournalEntryId (is_transaction_booked semantics).
- G3: the ledger scan excluded every voucher linked to any transaction,
  so a voucher booked from a date-drifted duplicate row escaped BOTH
  halves and the booking proceeded with no warning. A voucher whose
  linking transaction itself matches the target (same ore in the same
  currency, compatible cash account, date in the window) is now returned
  as the twin with transaction_id set.

All candidate picks keep explicit total-order tiebreakers so a force
re-detect returns the same candidate the user reviewed, and the
SEK-or-null amount contract is unchanged.

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

* fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog

The duplicate dialog hid its match action for sibling-transaction
candidates (canMatch required transaction_id === null), so the user who
most needed steering saw only 'Bokfor anda'. manualLink explicitly
allows N:1 links, so the match action is now offered for both candidate
kinds. Sibling candidates get question-form body copy ('vill du matcha
mot verifikatet i stallet?') and an additional 'Ignorera transaktionen'
action via the existing POST /api/transactions/[id]/ignore, which is the
correct resolution when the row itself is a duplicate import (matching
would double-count the bank side, booking the ledger side).

Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a
destructive toast with no way forward:

- the counterparty-template branch of handleQuickReviewConfirm now sets
  the shared duplicateWarning state exactly like runCategorize, with the
  force retry bound to the reviewed candidate's voucher
- BankReconciliationView's quick-book now opens the same dialog, with
  match/ignore refreshing the reconciliation lists

New sv/en strings: dialog_duplicate_body_sibling,
dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level
parity tests pin the 409 routing and the dialog affordances.

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

* fix(transactions): duplicate guard on the bulk-book samlingsverifikation path

/api/transactions/bulk-book never called detectBookingDuplicate, so a
batch containing an already-booked twin minted a second verifikat with
no warning. The route now runs the shared per-tx guard before the RPC,
with intra-batch exclusions (the other selected txs are distinct events
the user picked, and the link-existing target voucher is the batch's own
destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the
candidate and the flagged tx id.

BulkBookDialog routes the 409 into DuplicateBookingDialog for review
(view voucher / cancel / book anyway) instead of a dead-end toast;
'Bokfor anda' re-runs the batch with force=true. On force the route
re-detects and records each dismissed candidate as
BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2
kap 8), parity with the /categorize bypass. Detection failures stay
fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions)
bypasses this route and remains unguarded; guarding inside the RPC needs
a migration and is out of scope here.

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

* fix(transactions): gate the duplicate-dialog ignore hint on the action being present

The sibling body copy mentioned ignoring the row, but two render sites
(the manual booking form and the bulk dialog) show sibling candidates
without the ignore action. The guidance now lives in a separate
dialog_duplicate_ignore_hint string rendered only when the Ignorera
button itself renders, so copy never points at a button that is not
there.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:55 +02:00
Jakob Wennberg 1b829883ae feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)
* feat(reconciliation): accept confidence_threshold on the bank run route

Mirror the v1 route: RunReconciliationSchema gains an optional
confidence_threshold (0..1) that passes through to runReconciliation as
the server-side floor on the apply path. The UI sends 0.85 with a
strong-only apply so a pair the fresh re-run scores lower is skipped
instead of committed; omitting it keeps the legacy behavior where every
selected pair applies.

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

* feat(reconciliation): promote the bulk match flow and bridge it from the inbox

The dry-run preview with pre-ticked strong matches existed but was never
found: users matched whole migrations row by row. Three discoverability
changes, no engine changes:

- Bankavstamning: an attention line above the toolbar while unmatched
  transactions exist and no preview has run, with Forhandsgranska
  promoted to the filled variant. When every ticked preview pair is a
  strong match (>= 0.85) the apply button relabels to 'Matcha X starka
  traffar' and the apply sends confidence_threshold 0.85; mixed
  selections keep the plain label and omit the floor so manually ticked
  weaker pairs still apply.
- Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the
  preview once, only after appliedDates is set and not while datesDirty,
  so it can never cover a different window than the on-screen lists.
- Transactions inbox: with >= 5 unbooked bank rows visible, an attention
  line links to the reconciliation with autorun (static text + count, no
  probe; the preview is the honest source of how many actually match).

The review step stays: autorun lands on the preview table, one click
from apply, and the server intersection guard is untouched.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:27 +02:00
Jakob Wennberg 1eebb75269 feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.

New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.

UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.

New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:20:14 +02:00
Jakob Wennberg c420fd2aa1 refactor(transactions): drop the Q1-Q4 chips from the period filter (#1579)
The quarter chips (#1545) appeared only once a rakenskapsar was chosen,
pushed the filter row out of alignment, and on a brutet rakenskapsar
they were fiscal-year quarters rather than momsdeklaration quarters, so
they misled more than they scoped. The rakenskapsar filter itself (the
user-requested half) stays; lib/transactions/period-filter.ts keeps its
quarter helpers and tests since resolvePeriodBounds still serves the
full-year bounds.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:17:03 +02:00
Jakob Wennberg d583a4e5bb fix(supplier-invoices): gutter between table columns on the invoice detail (#1578)
The Rader and Betalningshistorik tables carried no horizontal cell
padding, so a right-aligned column ran flush into the next left-aligned
one: the headers read ANTALENHET / A-PRISKONTO / BELOPPVERIFIKATION and
the cells read "1st", "8 985 kr5010" and "11 231 krb9da9d7c". Every
column after the first now gets a pl-4 gutter, matching the dry-table
spacing (TD_CLASS px-4).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:52 +02:00
Jakob Wennberg 0643316ac8 feat(import): show SIE import history with undo on the import tab (#1574)
* feat(import): show SIE import history with undo on the import tab

The list route (GET /api/import/sie) and the undo route (DELETE
/api/import/sie/[id]/undo) both existed, but no UI ever called the list:
once the post-import result screen was gone, past imports could not be
seen or undone. Add a fold-open 'Tidigare SIE-importer' row on the
Importera tab (same expanded pattern as the cloud-backup row) that
lazy-loads a history table: filename, date, fiscal year, voucher count,
status, and an undo button on completed rows. Undo confirms through
DestructiveConfirmDialog (voucher count, IB cleared, documents detached
but kept; plus a voucher-gap warning for large imports), keeps the
dialog open for the long-running DELETE, and refetches on completion.

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

* test(import): cover the SIE list and undo routes

The base list route had no test file (its siblings all do) and the undo
route was only covered indirectly. Add route tests through the real
withRouteContext wrapper: 401, the { data, count, limit, offset } shape
with company scoping and range math, the status filter, and the
Swedish 500 path for the list; 401, 403 viewer, the
{ success, deletedEntries } passthrough, and the SIE_UNDO_FAILED
envelope (reason in details) for undo, with undoSIEImport mocked.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:41 +02:00
Jakob Wennberg e1f13f870a feat(import): warn about already-imported rows in the bank-file wizard (#1567)
* fix(transactions): paginate the ingest dedup maps past the 1000-row cap

buildExistingTransactionMaps issued un-paginated selects for the booked and
unbooked dedup maps, so PostgREST silently truncated each at 1000 rows: a
re-import over a wide date range in an active company deduped against a
partial map and inserted everything past the cap as duplicates. Both queries
now go through fetchAllRows with a stable .order('id') for range paging.

Also exports the function and its types for the upcoming read-only duplicate
preview, which must share the exact stored-row universe execute-side ingest
dedups against.

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

* feat(import): add read-only duplicate preview endpoint for bank files

New POST /api/import/bank-file/check-duplicates (withRouteContext + Zod,
transactions capped at 20000) computes external_ids with the exact
generateExternalId(tx, format, index) derivation execute uses and runs
previewDuplicates: Layer-1 id collisions plus the Layer-2 text bridge with
counting semantics and the currency guard, against the same stored-row maps
ingest builds (buildExistingTransactionMaps). The result is advisory; execute
stays authoritative and mirrors/settlement-account guards are documented
preview/execute differences.

A dedicated endpoint because the generic_csv path re-parses client-side and
never re-hits /parse. Also removes the dead existing_transaction_count field
from the parse response (a raw date-range count consumed by nothing).

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

* feat(import): surface duplicate rows in the bank-file import wizard

Overlapping bank imports used to dedup silently: the wizard promised
'Importera N transaktioner', ingest skipped the twins, and the user saw fewer
rows than parsed with zero explanation. The wizard now calls check-duplicates
after a successful parse AND inside handleColumnMappingConfirm (the
generic_csv path never re-hits parse), and:

- BankFilePreviewStep: warning card in the AlertTriangle pattern ('{count}
  rader finns redan', skipped automatically) plus a 'Finns redan' badge on
  flagged rows in the 50-row table
- BankFileConfirmStep: repeats the summary card (generic path skips preview)
  and the CTA counts 'Importera {parsed - duplicates} transaktioner'
- BankFileResultStep: renders result.duplicates when > 0, closing the loop
  ingest.ts documents as unrendered

Execute semantics unchanged: all rows are sent, ingest skips; the preview is
advisory and never promises an exact final number. New strings in both
messages/sv.json and messages/en.json next to the import_psd2 anchors.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:29 +02:00
Jakob Wennberg 7ccaab7a08 fix(agent): contain the floating assistant panel on open and resize (#1575)
A persisted float rect saved at the viewport edge, or on a larger
monitor, passes clampFloatRect (which only keeps 48px reachable so a
live drag may deliberately hang off an edge) and renders the panel as
a 48px sliver on every open. Add containFloatRect, which snaps the
whole window inside the viewport, and an AgentSheet effect that
validates the persisted rect on sheet mount and on viewport resize
and persists the corrected position. The rect is read through a ref
so drag commits do not re-trigger containment: parking the window
half off-screen still works within a session. The live drag path and
clampFloatRect are unchanged, and containFloatRect's output is always
a fixpoint of clampFloatRect, so the render clamp stays a no-op.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:14:34 +02:00
Jakob Wennberg 78a581bca1 fix(import): guard against CP437-as-CP1252 mojibake entering via pre-decoded SIE text (#1569)
* refactor(arcim-migration): remove the dead gateway SIE export path

fetchSIEExport and SIEExportFile have had zero callers since the direct
provider clients replaced the Arcim Sync gateway (#181, #718). The path
returned SIE as a pre-decoded string, and the gateway's decode of CP437
bytes as windows-1252 is what wrote the 2026-03-17 mojibake into posted
entries. Deleting it makes the string-typed SIE fetch impossible to
re-wire; a comment marks the grave. The consent lifecycle and entity
accessors stay untouched.

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

* fix(import): warn when SIE text carries CP437-as-CP1252 mojibake

The 2026-03-17 migration wrote mojibake ("L"neutbetalning"-style C1
specials) into posted entries because the retired gateway handed the
/import-sie handler an already-decoded string: byte-level encoding
detection never saw it, and nothing downstream checked. The live bug is
gone; this is the tripwire so the signature can never land silently
again.

- lib/import/sie-artifact-scan.ts: pure scanner over parsed SIE account
  names and voucher/line descriptions, reusing hasCp1252Artifact from
  charset-repair; flags at >= 2 hits so a lone legitimate curly quote or
  apostrophe cannot false-positive a whole file.
- arcim-migration /import-sie: warn-never-block; the Swedish warning
  rides on result.warnings, which the workspace UI already renders, plus
  a server-side log.warn.
- wizard parse route: same scan, surfaced through the existing
  parse-issue warnings card in the preview, pointing at the first
  affected line.

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

* test(bookkeeping): pin the reported gateway mojibake strings

Adds the four strings reported from the affected company's journal as
reverse_cp437 cases (all reverse losslessly) plus a false-positive
guard: space-padded typography must never route into the CP437
reversal.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:14:23 +02:00
Jakob Wennberg c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:32 +02:00
Jakob Wennberg ebeaeec80e docs: decision log for the 2026-08-13 feedback batch (#1576)
Ten entries for the Johan Lind feedback fixes (PRs #1565-#1575): the
non-obvious calls live here in one commit instead of ten conflicting
appends, per the standing concurrent-PR DECISIONS.md conflict gotcha.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:19 +02:00
Jakob Wennberg f8507d38ae fix(settings): land Medlemmar clicks on the members section (#1566)
The user-menu link pointed at /settings/team, but in-app navigation is
intercepted by the settings modal, whose section map has no team entry:
unknown sections fall back to Företag, leaving the user to scroll and
find Medlemmar themselves. Link to /settings/company#members instead,
and scroll the members section into view when it mounts (ref callback,
since the content mounts after the settings fetch). The hash is cleared
after scrolling so tab-switching back to Företag stays put.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:36:58 +02:00
Mattsson 5769e35869 fix(api-v1): propagate underlag when booking via v1 categorize routes (#1564)
The v1 categorize and batch-categorize routes create the journal entry
via createTransactionJournalEntry directly and never ran the shared
underlag propagation, so a booking made through the API-key surface
left the transaction's pinned document unanchored and matched inbox
items unstamped: the same "Underlag saknas" gap #1560 closed for the
dashboard, /book and bulk-book paths, surviving on this one surface.

Both routes now call propagateUnderlagForBookedTransaction after the
CAS write succeeds (only when this request owns the booking; skipped on
partial success and lost CAS races). Best-effort by contract, same as
every other caller: a propagation failure is logged inside the helper
and never fails the booking.

Also adds the attach-after-bulk-book unit test salvaged from the closed
duplicate PR #1559: a document pinned to a bulk-booked transaction
(verifikat anchored via transaction_voucher_links) is anchored against
the samlingsverifikat when attached after the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:48:01 +02:00
Mattsson d9dddba682 fix(transactions): anchor the pinned document to the verifikat on booking (#1560)
A document pinned to a transaction (transactions.document_id) with no
unconsumed inbox item was never anchored onto the verifikat when the
transaction was booked: document_attachments.journal_entry_id stayed
null and every underlag surface reported "Underlag saknas" for a
booking that HAS its underlag (attach-before-book via the manual
booking dialog, the 2026-08-13 user report).

PR #1547 already routed /book, bulk-book and categorize through the
shared propagateUnderlagForBookedTransaction helper, but that helper
only walked matched inbox items. This adds a pinned-document leg to the
helper, so all booking paths anchor the pin in one place:

- the pin is read fresh inside the helper (not from the caller's
  pre-booking snapshot) so a concurrent attach is still anchored
- same guard semantics as inbox docs, via the extracted
  anchorDocumentToJournalEntry: no-op when already anchored to this
  verifikat, never steal another verifikat's underlag, log-and-continue
  on failure (the booking is already posted; a re-run repairs the link)
- the bulk-book RPC already anchors pins atomically, so the leg no-ops
  there

Route tests cover the three plan cases: pinned doc anchored, matched
inbox item stamped, and propagation failure never failing the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:50:03 +02:00
Jakob Wennberg 314efe8b22 fix(design): stop synthesizing bold on Hedvig display headings (#1555)
* fix(design): stop synthesizing bold on Hedvig display headings

Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule
forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold
on top, so every display heading rendered browser-synthesized bold: the
smudged heavy look on dialog titles and page headings. Drop the base
rule to 400, remove the weight utilities from the title primitives, and
sweep the 41 files that hand-set font-medium/semibold/bold on serif
headings (a pattern design.md already forbids). Headings that opt into
font-sans keep their weight.

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

* fix(design): drop empty className left by the weight sweep

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

---------

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