1704 Commits
Author SHA1 Message Date
admin 9228896b62 Merge pull request 'fix(worklist): skattekonto count-test tidsbomb (förfallodatum passerat)' (#14) from fix/skattekonto-count-timebomb into main
masterplan-lock / check (push) Successful in 6s
2026-09-24 13:17:33 +00:00
Claude Code 5d9b220fa8 fix(worklist): countSkattekontoPaymentDue accepts today — test time-bomb (due date 2026-09-12 expired vs wall clock)
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 6s
2026-09-24 15:16:46 +02:00
admin e0d2923d4c Merge pull request 'fix(ci): use internal pinned action mirrors' (#13) from fix/internal-action-mirrors into main
masterplan-lock / check (push) Successful in 8s
2026-09-24 12:43:19 +00:00
Claude Code a909043176 fix(ci): use internal pinned action mirrors (github.com egress flaky from runners)
masterplan-lock / check (push) Successful in 12s
masterplan-lock / check (pull_request) Successful in 11s
2026-09-24 14:42:40 +02:00
admin e60f2af6cd Merge pull request 'chore(deps): update dependency mailparser to v3.9.28' (#11) from renovate/mailparser-3.x-lockfile into main
masterplan-lock / check (push) Failing after 38s
2026-09-24 12:11:19 +00:00
admin c90ccc9c42 Merge branch 'main' into renovate/mailparser-3.x-lockfile
masterplan-lock / check (pull_request) Failing after 36s
masterplan-lock / check (push) Failing after 7m33s
2026-09-24 12:11:15 +00:00
admin 7b0e59257e Merge pull request 'chore(deps): update dependency vitest to v4.1.11' (#12) from renovate/vitest-monorepo into main
masterplan-lock / check (push) Failing after 5m28s
2026-09-24 12:11:12 +00:00
Renovate 14e6aead84 chore(deps): update dependency vitest to v4.1.11
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (pull_request) Successful in 10s
masterplan-lock / check (push) Successful in 12s
2026-09-24 03:08:10 +00:00
Renovate 4986cfe27b chore(deps): update dependency mailparser to v3.9.28
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (push) Successful in 9s
masterplan-lock / check (pull_request) Successful in 11s
2026-09-24 03:07:58 +00:00
Claude Code 48c762f7e0 feat(estate): härlett siax.repo.v3.json (estate-manifest-gen v0)
masterplan-lock / check (pull_request) Successful in 13s
masterplan-lock / check (push) Successful in 58s
Tekniska fält härledda ur checkouten (runtime, gitHost/canonicalRemote,
neutrala persistence/observability/reliability — aldrig påstådda).
POLICY-FÄLT (tier/category/appId) är neutral-baseline MED appIdJustification
som säger att ägaren ska sätta dem (D4) — förgranska och korrigera.
2026-09-24 01:22:27 +02:00
admin 2046ef4089 Merge pull request 'chore(deps): update dependency eslint to v9.39.5' (#8) from renovate/eslint-monorepo into main
masterplan-lock / check (push) Successful in 37s
Reviewed-on: sax3l/accounted#8
2026-09-23 21:22:05 +00:00
admin 66e65390c2 Merge branch 'main' into renovate/eslint-monorepo
masterplan-lock / check (push) Successful in 22s
masterplan-lock / check (pull_request) Successful in 39s
2026-09-23 21:21:55 +00:00
admin ac88b1a6a0 Merge pull request 'chore(deps): update dependency jszip to v3.10.2' (#9) from renovate/jszip-3.x-lockfile into main
masterplan-lock / check (push) Successful in 14s
2026-09-23 21:20:07 +00:00
Renovate 592cfd1ed7 chore(deps): update dependency jszip to v3.10.2
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (pull_request) Successful in 8s
masterplan-lock / check (push) Successful in 9s
2026-09-23 03:05:16 +00:00
Renovate 3299eeec47 chore(deps): update dependency eslint to v9.39.5
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (push) Successful in 10s
masterplan-lock / check (pull_request) Successful in 9s
2026-09-23 03:05:13 +00:00
admin e1484e74f9 Merge pull request 'fix(deps): replace dependency framer-motion with motion' (#4) from renovate/framer-motion-replacement into main
masterplan-lock / check (push) Successful in 13s
2026-09-22 11:00:36 +00:00
admin dd1c05f114 Merge branch 'main' into renovate/framer-motion-replacement
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 1m15s
2026-09-22 10:20:19 +00:00
admin 5f2df18abe Merge pull request 'chore(deps): update dependency @supabase/ssr to v0.12.7' (#5) from renovate/supabase-ssr-0.x-lockfile into main
masterplan-lock / check (push) Successful in 6s
2026-09-22 10:20:18 +00:00
admin 8e8c8f66a4 Merge branch 'main' into renovate/supabase-ssr-0.x-lockfile
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 7s
2026-09-22 10:20:04 +00:00
admin 4bc9bb64bc Merge pull request 'chore(deps): update dependency @upstash/redis to v1.38.4' (#6) from renovate/upstash-redis-1.x-lockfile into main
masterplan-lock / check (push) Successful in 1m24s
2026-09-22 10:20:02 +00:00
Renovate 5aabd3d41a chore(deps): update dependency @upstash/redis to v1.38.4
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (pull_request) Successful in 6s
masterplan-lock / check (push) Successful in 11s
2026-09-22 03:07:46 +00:00
Renovate 1169e0ddff chore(deps): update dependency @supabase/ssr to v0.12.7
renovate/stability-days Updates have met minimum release age requirement
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
2026-09-22 03:07:39 +00:00
Renovate 8ef8e8bb52 fix(deps): replace dependency framer-motion with motion
renovate/stability-days Updates have not met minimum release age requirement
masterplan-lock / check (pull_request) Successful in 16s
masterplan-lock / check (push) Successful in 16s
2026-09-22 03:07:31 +00:00
admin 11f02686d5 Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main
masterplan-lock / check (push) Successful in 6s
2026-09-21 21:39:09 +00:00
admin feb5e765f2 Merge branch 'main' into renovate/configure
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 1m20s
2026-09-21 21:20:56 +00:00
admin e4c35c344d Merge pull request 'chore(backup): initial siax.backup-post (RW-002)' (#3) from chore/backup-post-2026-09-21 into main
masterplan-lock / check (push) Successful in 5s
2026-09-21 02:47:23 +00:00
siax-ci 0b296b927f chore(backup): initial backup-policy post (RW-002 automat) — källa: flottloggar
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 1m13s
2026-09-21 02:41:06 +00:00
admin 6a0ba9faaa Merge pull request 'feat(toolchain): W4-slutvåg — mise task-kontrakt + lefthook + template-pin + dokumentation' (#2) from feat/selected-toolchain-w4 into main
masterplan-lock / check (push) Successful in 7s
2026-09-15 00:38:13 +00:00
admin 42241a9ad3 feat(toolchain): W4-slutvåg — mise task-kontrakt (genererad ur toolchain-discovery) + lefthook + template-pin + dokumentation
masterplan-lock / check (push) Successful in 8s
masterplan-lock / check (pull_request) Successful in 5s
2026-09-15 02:30:45 +02:00
admin 07eb82af5d feat(toolchain): W4-slutvåg — mise task-kontrakt + lefthook + template-pin + dokumentation
masterplan-lock / check (push) Successful in 5s
2026-09-15 02:28:57 +02:00
Renovate 97e17964ad Add renovate.json
masterplan-lock / check (push) Successful in 16s
masterplan-lock / check (pull_request) Successful in 6s
2026-09-11 03:05:21 +00:00
siax-bot 5b7015d88d fix(plan): komplettera lock-scriptet-krav (aldrig egen SQL, frontend-tenant-trust, PRODUCTION_READY)
masterplan-lock / check (push) Successful in 5s
2026-09-10 13:30:37 +02:00
siax-bot d8463e7ffe feat(scaffold): SIAX masterplan-lock-gate + PLAN/MASTERPLAN_INDEX.md integrering
masterplan-lock / check (push) Failing after 5s
2026-09-10 13:27:19 +02:00
MattssonandClaude Fable 5.1 51f05ffeba feat(dashboard): dismissible system notice banner for every signed-in user (#2464)
CodeQL / Analyze (javascript-typescript) (push) Failing after 10m53s
CodeQL / Analyze (actions) (push) Failing after 10m43s
Build and Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build and Push Docker Image / Merge, sign and scan (push) Has been cancelled
Build and Push Docker Image / Build linux/amd64 (push) Failing after 3m4s
Workflow audit (zizmor) / Audit workflows (push) Failing after 5m54s
* feat(dashboard): dismissible system notice banner for every signed-in user

Operator-set banner ("high load right now, some pages may respond slowly
or fail") rendered under the dashboard chrome for every signed-in user
while NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL (ISO timestamp with offset) is in
the future. Closing it stores the deadline in localStorage, so each
browser sees it once; the banner hides itself at the deadline in open
tabs and is not rendered at all after it.

Why the problem occurred: there was no way to tell every user something
about the system itself. The existing banners are all per-company state
(sandbox, seat grace), so an operator notice had no home.

What was removed or simplified instead: no notices table, no migration,
no admin UI. One public env var carries both the on/off switch and the
expiry, and the same value is the dismiss key, so a later notice re-shows
once without any code change. No DB read, which matters because the
first use is a DB restart window.

Why this over the proposed shape: the request was a banner "until 23:00
tonight". Hardcoding that in code would need a second PR to switch off
or reuse; a DB-backed notice would read the database that is about to
go down. The env var expires on its own, and unset means gone.

Fixes #2463

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

* fix(dashboard): system notice survives long deadlines, blocked storage, and every layout shell

Skeptic findings on 839a255f3:
- setTimeout clamps delays above 2^31-1 ms to ~1 ms, so a deadline more
  than 24.8 days out hid the banner instantly. Wait in bounded steps and
  re-check the clock.
- window.localStorage is a throwing property access when a browser blocks
  site data; read it behind a try so the dashboard never crashes over a
  notice.
- The close button was a hand-rolled 22px icon button; design.md requires
  the shadcn icon Button (40px target).
- The byrå-consultant shell and the stale-cookie shell rendered no banner,
  so "every signed-in user" was not true. The banner is now computed once,
  before the shell branches, and mounted in all three.

Refs #2463

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

* fix(dashboard): system notice deadline requires a UTC offset

A date-time without Z or a numeric offset parses as local time, which is
UTC on Vercel and the operator's zone locally, so the same value would
mean different instants. Reject it instead (CodeRabbit on #2464).

Declined: scoping the dismissal key by user id. The notice is about the
system, not the account; per-browser dismissal is the sandbox banner's
semantics and keeps identity out of layout chrome.

Refs #2463

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:15:27 +02:00
MattssonandClaude Fable 5.1 a84d2723e0 feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import

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

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

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

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

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

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

Fixes #2427

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

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

Review findings on PR #2458, one pass:

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

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

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

---------

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

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

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

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

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

Fixes #2455

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

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

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

Refs #2455

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

---------

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

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

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

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

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

Fixes #2453

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

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

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

Refs #2453

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

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

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

Refs #2453

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Fixes #2448

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

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

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

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

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

Refs #2448

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:43:14 +02:00
cb9eedd7f2 fix(bank): unchecked accounts yield their bokföringskonto to a checked one in the picker (#2449)
Unchecking the wrong bank account and putting the right one on 1930
answered 400 "Flera bankkonton kan inte bokföras på samma konto": the
collision pass counted every stored account as a claim, the picker
hides the ledger dropdown for unchecked rows, and disconnect plus
reconnect re-claims the same cash_accounts rows by IBAN. No route out
(support case 2026-09-09, two 400s on the route in the Vercel logs).

Checked accounts stay hard claims (duplicate and foreign-live = 400).
Unchecked accounts hold their ledger as a soft claim: kept and mirrored
with enabled=false unless a checked account wants it, then they yield
and lose the prefill. Contested rows (this connection's row for a
yielded or moved account, another connection's row for an account
unchecked there) are demoted to manual in one update before the mirror,
so upsertFromPsd2 promotes the holder in place and row ids, transaction
links and the is_primary flag on the 1930 row survive. The same pass
makes two checked accounts swapping ledgers work, which previously
tripped the unique constraint in both upserts and was swallowed.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:29:21 +02:00
MattssonandClaude Fable 5.1 6ea92f3152 feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed).

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

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

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

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


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

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

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

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

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


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

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

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

Closes #2196


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:19:19 +02:00
5e2498bc2f feat(transactions): pick several ROT/RUT begäran by hand for one Skatteverket transfer (#2431)
The manual invoice picker's ROT/RUT section handed over exactly one
begäran, while the route, the settle service and the confirm dialog take
a bundle since #2360. When the automatic set matcher refuses a transfer
(two open begäran with the same amount, or more than four), the user had
no way to build the bundle and was told to split the bank row.

Each begäran row now carries a checkbox; ticking one or more shows the
running sum against the bank row and a "Matcha valda" button that hands
the set (largest first, the matcher's order) to the existing confirm
dialog, which still refuses a sum that is off the row. A plain row click
keeps the one-begäran path.

Part of #2425 (the suggestion itself shipped in #2271 and #2360).


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

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

Closes #2426


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

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

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

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

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

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

Fixes #2224

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

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

Skeptic refutations on the offert -> kundorder change:

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

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

Refs #2224

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

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

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

Refs #2224

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

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

Correctness skeptic refutations on the offert -> kundorder change:

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

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

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

Refs #2224

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

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

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

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

Refs #2224

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

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

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

Refs #2224

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

---------

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

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

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

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

Fixes #2437

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

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

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

Refs #2437

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

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

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

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

Refs #2437

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

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

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

Refs #2437

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

---------

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

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

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

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

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

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

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

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

Skeptic findings on ab119d6ed:

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

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

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

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

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

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

CodeRabbit on #2439:

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:10:34 +02:00
bjornbergenheimandClaude Opus 5 c091a7f28e fix(ui): clip the flexible list cell on the rows #2003 missed (#2284)
#2003 gave TransactionInboxCard overflow-hidden because its shrink-0 row
markers cannot truncate: past the width that fits them the cell painted
over the Belopp column instead of clipping. Six other rows carry the same
`max-w-0 w-full` flexible cell with shrink-0 chips inside and never got
the guard, so they still overlap the neighbouring column.

Reproduced on /transactions with a Skatteverket row whose booking
suggestion is long ("Bokförs mot 2731 Avräkning lagstadgade sociala
avgifter"): the text runs straight through the amount. Seen in both
Chrome and Firefox, so this is not engine specific; Firefox only reaches
it sooner, because it ignores the max-w-0 cap on a td when sizing
columns (CSS 2.1 10.4 leaves max-width on table cells undefined).

Rows fixed: the skattekonto inbox row, both rows in the transaction
history list, the verifikat list, the chart of accounts, the customer
list and the salary run list. ChartOfAccountsManager's second flexible
cell is left alone: its only child truncates, so it has nothing that can
overflow.

Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:07:33 +02:00