Commit Graph
532 Commits
Author SHA1 Message Date
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 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
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
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
MattssonandClaude Fable 5.1 26e29f47bc feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1)

Why the problem occurred: the legal form was modelled as a binary flag in
~300 files. `EntityType` was a two-member union, but nothing dispatched on it
exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize,
match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut,
MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B`
ternary. Widening the union compiled everywhere and changed nothing, so a
förening would have booked as an enskild firma in the app and as an
aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused
föreningar at the door (mapEntityType returned null), which is what the
tester hit.

What was removed or simplified: the silent defaults. One module,
lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser
(never defaults), the resolver (settings hint, then companies.entity_type,
then throw) and `byEntityType`, whose Record arms make the compiler refuse
the next widening until each site has an answer. The form-dependent facts
(closing account, owner settlement account, calendar-year lock, default
method, K1/K2 label, personnummer vs 16-prefix) live there once instead of
in the ternaries. On the SQL side supported_entity_types() replaces four
copies of the literal list in the create RPCs.

Why this shape and not the proposed one: the tracker asked for the enum
widening plus a chart; that alone was the dangerous version (compiles, books
wrong). Bundling stiftelse was considered and dropped: identical plumbing but
no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so
the CHECK, RPCs and seed can ship now and the first partner is switched on
without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut,
Swish) lands on the tracker.

Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before
Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner
accounts, member settlement on 2890; accrual default; brutet räkenskapsår
allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org
number gets the 16 prefix.

Migration 20260908110835 widens the three CHECK constraints, adds
supported_entity_types(), re-creates the three create RPCs with the widened
guard and adds the förening block to seed_chart_of_accounts. Applied to
staging and covered by ideell-forening-entity-type.pg.test.ts.

Part of #2072

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

* fix(company): close the förening paths the skeptic refuted (#2072)

Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the
shared definition rather than the reported site:

1. Privately paid supplier invoices and the utlägg dialog resolved the owner
   account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening
   member's invoice was built on 2893 and then refused by the expense-claim
   service (which already said 2890), burning an ankomstnummer. The helper now
   uses ownerSettlementAccount.
2. Booking templates substitute their `_ab` accounts only for an aktiebolag;
   the `private_expense` template kept its base 2013 for a förening. Template
   accounts now resolve through templateAccountForForm: EF base, AB override,
   förening base with owner accounts translated to 2890 (booking-templates.ts
   and proposal-lines.ts share it).
3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline:
   the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk
   person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule
   now keys on fiscalYearLockedToCalendar instead of the two literals; same in
   the MCP VAT report.
4. 2069 would have accumulated across years: the year-open omföring was
   AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the
   pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068)
   and skips forms with no carry (EF).
5. With the flag off, a registry lookup that returned "Ideell förening" was
   prefilled into the onboarding journey, the form picker was skipped and the
   create step answered "Ogiltig företagsform" with no way back. The
   journey, the BankID picker, the onboarding page and the MCP lookup now use
   mapSetupEntityType, which maps only creatable forms, so a flagged-off form
   falls through to the picker as before.

Also: form picker keeps its AB-first order; tests for each fix.

Part of #2072

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

* chore(migrations): move ideell förening migration after main's latest version (20260908143051)

Two migrations landed on main after the branch forked; a lower version
would be skipped by the merge-time apply. Staging history row renamed to
match.

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

* chore(skills): regenerate accounted-api reference for the widened entity_type enum

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:47:50 +02:00
MattssonandClaude Fable 5.1 e85eac317c feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* feat(arsredovisning): manual override for medelantal anstallda (Not 2)

Why the problem occurred: the ÅRL 5:20 § note was derived only from the
employees table, and most aktiebolag that book salary never create a
Löner employee record (hand-booked salary, SIE import, migrated
history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have
no employees rows, so their note reads "inga anställda". The reporting
company had one row created the same day with a start date halfway
through a July-June year: 181/365 = 0.5 FTE rounds to 0.

What was removed or simplified: nothing removed. One resolver
(resolveMedelantalAnstallda: override, else FTE average) now feeds the
K2 note, the K3 note and the iXBRL fact, so no reader can pick a
different number. The override sits on arsredovisning_narratives next to
the other ÅRL 5 kap. disclosures and rides the existing narrative
GET/POST route, service and page save.

Why this and not the proposed one: the request asked support to "enable
override of Not 2" as free text. A whole number keeps the statutory
sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free
text would allow a non-compliant note. Rounding 0.5 up globally was
rejected (changes every company's note silently, does nothing for
companies with no employees rows), as was backdating the hire date
(fixes one company, misstates the fact).

The iXBRL input also reads the previous period's override so the
jämförelseår column shows what last year's document showed.

Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and
is applied and tracked on staging.

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

* fix(arsredovisning): size-threshold metrics use the medelantal override too

Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read
the FTE average from the employees table, so the ÅRL 1:3 § större-
företag test and the K2 relief thresholds could disagree with the figure
Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no
employees rows and an override of 60 both years, balansomslutning over
40 MSEK, would have validated as K2-eligible while its own document said
60 employees.

Fix: the metrics resolve the employee figure the same way the note does
(current period override from report.disclosures, previous period via
getMedelantalOverride on that period's narrative row). previous_period
on ArsredovisningData now carries the period id so the lookup needs no
extra fiscal_periods read. The iXBRL employees-error fallback keeps the
previous period's override instead of blanking the jämförelseår.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:44:29 +02:00
MattssonandClaude Fable 5.1 2303f75a7b fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage

Why the problem occurred: the supplier register was written in three
spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored
whatever the caller sent, the AI extractor emits bare digits) while
matchSupplierByIdentity compared raw strings with .eq(). The canonical rule
existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey,
the extractor's toOrg10) and nowhere on the path that decides a match, so
every AI-extracted invoice from a hyphen-registered supplier missed the
strongest key and fell to exact-name matching. Prod holds 1738 hyphenated
rows against 493 bare ones.

What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10
of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two
other copies. The matcher scans the company's suppliers with an org_number
and compares keys, the same shape as its vat_number branch, so rows written
before the backfill (and self-hosted instances that never run it) match too.
CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier
schema store the key; the form renders it through formatOrgNumberDisplay.
A backfill migration strips the formatting from existing rows, skipping
migration-reset source companies.

Why this and not the proposed one: the issue's third layer (CHECK plus a
unique index) would fail to create on prod, which holds 94 duplicate
(company_id, key) groups across 18 companies, one of them 124 rows under a
single placeholder-looking number; that needs a merge decision first and is
filed as #2404. Rejecting anything that is not 10 or 12 digits on write was
also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL,
FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is
canonicalised and anything else is stored as typed. Luhn stays lenient on
suppliers because two rows with the same mistyped number are one supplier
and parties is Luhn-strict at promotion already.

Fixes #2391

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

* fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key

Skeptic pass on the previous commit. Three refutations, all confirmed:

1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A
   VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to
   6012579001, another company's identity, on every write path and in the
   backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A
   Belgian BE0123456789 lost its country letters the same way. The key now
   strips only hyphens and spaces and unprefixes 12 digits only behind
   16/18/19/20; everything else is null, stored and compared as typed. The
   migration carries the same rule.
2. The supplier list search, the v1 ?search= filter and the list column all
   used the raw stored value, so a user searching 556677-88 after the
   backfill found nothing. Both searches now compare without separators and
   the column renders XXXXXX-XXXX.
3. Storage was not canonical on every path: the CSV import and the provider
   migration orchestrator wrote as typed and keyed their re-sync dedup by
   the raw value, so a Fortnox re-sync sending 556677-8899 would have
   duplicated the now-bare row. Both write and key through orgNumberKey.

Also: the matcher scans live suppliers only, so a register holding an
archived hyphenated row next to its live replacement resolves to the live
one instead of whichever id sorts first.

Refs #2391

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

* fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical

CodeRabbit findings on PR #2405, all verified against the code:

- The supplier list rendered through formatOrgNumber, which strips letters
  and would show BE0123456789 as 012345-6789; it now uses
  formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone.
- The CSV import dedup fell back to digits-only, so BE0123456789 and
  FR0123456789 collided; the fallback is now the value as typed, in both
  the parse preview and the execute route.
- The provider migration's supplier-invoice stub map was keyed by the raw
  provider value while the stored row was canonical, so 556677-8899 and
  5566778899 on two invoices produced two stubs; the key goes through
  orgMapKey like the other maps.
- v1 response examples show the stored 10-digit form; the request example
  keeps the hyphenated input.

Refs #2391

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

* docs(api-skill): regenerate suppliers reference for the canonical org_number example

Refs #2391

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:59:20 +02:00
MattssonandClaude Fable 5.1 25b969183c fix(invoices): wrap the Visa PDF handler so the click event is not read as options (#2403)
The review pass on #2401 gave previewPDF an options parameter but left the
button's onClick as a bare reference, so TypeScript saw a MouseEvent flowing
into { asDraft?: boolean } and the production build failed (Core Build and
the Vercel deploy on 4e8d649b2). Prod stayed on the previous deploy.

Refs #2399


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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 01:32:21 +02:00
MattssonandClaude Fable 5.1 4e8d649b2f fix(invoices): Ej skickade view and a prompt before downloading a draft-stamped PDF (#2401)
* fix(invoices): show finalized-but-unsent invoices as their own view and ask before downloading a draft-stamped PDF

Two user reports, one hidden state: an invoice that went through Granska
& skapa has an F-number but its DB status is still 'draft' until it is
marked as sent (and booked). The list lumped those rows under Utkast, and
"Ladda ner PDF" handed out the UTKAST-stamped render with no warning, which
users then mailed to customers.

Why it occurred: the status column carries two meanings for 'draft'
(unnumbered draft vs numbered, unsent invoice) and every surface decided on
its own how to read it. The list badge knew the difference ("Ej skickad"),
the tab predicate and the PDF download did not.

What was simplified: the tab predicate moved out of the page into
lib/invoices/invoice-list-tabs.ts as one function used by the rows, the
per-view counts, the status sections and the row badge, so the four cannot
drift. The ?status= alias parsing collapsed into the same module.

Why this and not the proposed shapes: a real 'issued' status in the DB
would touch MCP, the v1 API, reports and SIE for a distinction that
invoice_number already carries. Removing the UTKAST stamp from numbered
drafts would be wrong: an unbooked invoice is not issued. So the UI splits
the state (Ej skickade view, ?status=unsent, ?status=godkanda alias) and the
download asks first: "Bokför och ladda ner" (or "Markera som skickad och
ladda ner" for cash-method companies and offerter) runs the existing manual
mark-sent dialog and then downloads the issued document; "Ladda ner utkast"
still works. The manual mark-sent toast now offers "Ladda ner PDF" too.

Fixes #2399

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

* fix(invoices): skeptic round on the draft-download prompt

Four refutations on 352d4bafc, all confirmed in code:

- Proformas (and any non-faktura) in an accrual company were told "Fakturan
  är inte bokförd" and offered "Bokför och ladda ner"; mark-sent never books
  a proforma. The label predicate is now issuesByBooking = booksOnIssue &&
  isRealInvoice, shared with the existing primary button, which carried the
  same "och bokför" promise on proformas.
- A partial mark-sent success (PDF archive, periodisering or delivery history
  failed) still ran the chained download, and its toast evicted the warning
  (one toast at a time). onSuccess now carries `partial`; the chained
  download is dropped on a partial result, matching the toast action.
- Numbered följesedlar are stamped UTKAST too (pdf-template does not exclude
  them) but the decision skipped the prompt. They now get the prompt; the
  issue action is their own status flip, so updateStatus reports success and
  the download is queued only after it.
- The download queue was a boolean bound to "whatever invoice is mounted";
  the detail pager keeps the page mounted across ArrowLeft/ArrowRight, so a
  step could download the neighbour or leave the queue armed. The queue now
  holds the invoice id and is dropped when a different invoice is shown.

Refs #2399

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

* fix(invoices): review pass on PR #2401

CodeRabbit, all confirmed against the code:

- The PDF preview (Visa PDF) bypassed the draft prompt; the browser viewer
  has a save button, so an unwarned preview is an unwarned download. The
  preview now runs the same decision; the prompt's draft button honours the
  original intent ("Visa utkast" opens the viewer, "Ladda ner utkast" saves).
- updateStatus lost its isUpdating reset when both branches started
  returning; moved to finally so a failed refetch after mark-sent does not
  leave the page's buttons disabled.
- A cancelled credit note matched both the Kreditfakturor and Makulerade
  views; the credit view now excludes cancelled rows like every other view.

Declined: the DECISIONS.md date (2026-09-08 is the local date the decision
was recorded; the bot compared against UTC) and the docstring-coverage
warning (not a repo gate).

Refs #2399

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 01:10:14 +02:00
MattssonandClaude Fable 5.1 fdcb7d937e feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle

Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT.

- /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut,
  refused to book, ready to request) and one row per begaran with mark
  uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor
  button links here, ?rot-rut=1 still opens the file dialog.
- Beslutsfil import from the UI through the existing import route.
- Reclaim of the share Skatteverket refused: one voucher debit 1510 /
  credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to
  the begaran and guarded by a partial unique index; the invoice reopens
  for the refused share via invoices.deduction_reclaimed_total, with the
  customer-share formula and its SQL twin gaining the same term. The
  payment dialog and bank match then settle the reopened remaining as a
  plain 1510 clearing; a booked kontantmetod invoice is proposed accrual-
  shaped so revenue is never recognised twice. Unknown per-invoice split
  of a partial beslut is refused, never allocated.
- MCP: gnubok_list_rot_rut_payout_requests (search-only read) and
  gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout)
  sharing one pre-flight + settle with the dashboard match route.
- Migrations 20260907140000 (reclaim state, source_type, INSERT guard),
  20260907140100/140101 (pending_operations op type).

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

* chore(rot-rut): renumber migrations after merging main

Main already carries 20260907143000 and 20260907150000, so the three
rot-rut migrations move to 20260907160000/160100/160101 to keep the
applied order monotonic (see memory: migration-version-collisions).

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

* fix(rot-rut): close the reclaim gaps found by skeptics, CI and review

Skeptic refutations (#2397):
- payment-sync recomputes remaining with deduction_reclaimed_total, so a
  storno of a payment on a reopened invoice no longer strands the refused
  share (R1).
- Reclaim refused while an invoice sits in a later live begäran
  (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide
  the action for the same case (C2).
- A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED)
  until the reclaim voucher is reversed (R2/C3).
- Storno of the reclaim voucher syncs the invoices and the begäran back
  (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3).
- A paid invoice with NULL paid_amount counts its customer share as paid
  (C4). Crediting an invoice with a reclaimed share is refused on the
  dashboard, v1 and MCP paths (R4).

CI and review:
- Build: custom-coded MCP errors via Object.assign, not codedError.
- pg-real: column default for default_voucher_series_per_source_type
  re-stated with rot_rut_reclaim (20260907160200); the default test now
  re-applies the latest default migration.
- Checks: accounted-api skill regenerated (journal-entries source types).
- CodeRabbit/Superagent: per-item refused shares must reconcile with the
  request-level beslut; per-invoice reopen through the idempotent RPC
  apply_rot_rut_reclaim_invoice (20260907160300) with a resume path;
  update-stage settle failures keep the voucher id (failed_partial);
  Stockholm calendar date for the booking; existing-voucher tab uses the
  same proposal method; MCP stage checks bank_line junction rows.

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

* fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut

- The shared match outcome now declares journalEntryId on update-stage
  errors, matching the settle service (Core Build TS2339 on 2d6cece1a).
- The reclaim voucher is dated on the Swedish calendar day of Skatteverkets
  beslut (decided_at), today only when no decision date is recorded, and
  the confirm dialog states the date (Swedish accounting review: BFL 5 kap
  6-7 §, datum for affarshandelsen).

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

* fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column

- apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300
  signature) takes only the refused share, validates it against the locked
  item, request and invoice, and derives remaining_amount and status from
  the INSERT-guard formula (review: caller-supplied accounting values,
  CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed
  reclaim voucher; the request link is cleared only after every leg.
- v1 credit route projection includes deduction_reclaimed_total so the
  reclaim guard actually fires there.
- Overview keeps "Bokfor nekat belopp" available while legs are pending
  (resume after a partial failure).
- Match and settle routes attach journal_entry_id on update-stage errors.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:13:05 +02:00
5d735097a1 fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)
* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only

markPeriodClosedExternally decided "migrated" by asking whether the period
had any non-imported verifikat at all. That was a proxy for the thing the
guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap),
and it shut out the migrated first year whose only native voucher re-keys
the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE
import. With the next year's IB already imported, the normal year-end
refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all.

The guard now asks the ledger the real question: are there lines on result
accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk
head counts with early exit, no line fetch, no journal_entries!inner embed.
The refusal message names what was found and why the normal year-end is
the remedy.

The loaded årsredovisning view also gets the FyPicker in its header. The
no-period branch auto-jumps to the remembered scope (or the newest year)
before its own picker is ever seen, so a user whose scope pointed at the
historical year had no way to reach the current year's årsredovisning
except by changing the scope on some other page and coming back.

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

* fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8

Triage of the swedish-compliance review:

- Balance-sheet-only native years bypassing the bokslut: partly real. The
  result transfer is not the only thing the normal year-end does; it also
  posts the next year's IB. A natively bookkept year with only balance-
  sheet vouchers (dormant AB, aktiekapital deposit only) could have been
  klarmarkerad and its balances would never have reached the next year.
  The leg now additionally requires the next period to carry IB already,
  which is exactly the shape where the normal year-end refuses
  (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB
  the normal year-end works and stays the remedy, and the message says so.
  The suggested IB-correction tag on entries was not adopted: the ledger
  already answers the question, a tag would be one more concept to know.
- Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9
  interna poster no longer count as result accounts. Storage format is the
  4-digit BAS string every classifier in the codebase already relies on
  (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as
  those.

Tests: next year with IB passes, next year without IB refused, no next
year refused, lt('account_number', '9') asserted.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:10:41 +02:00
MattssonandClaude Fable 5.1 7a30f623ba fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

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

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

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

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

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

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

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

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

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

* docs(invoices): state the bundled-font width margin accurately

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

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:45:38 +02:00
0016732232 feat(invoices): let the invoice list read by row tone without the status column (#2353)
* feat(invoices): let the invoice list read by row tone without the status column

A user asked for green/yellow/red row tints in the fakturalista (#2215) so
the list can be read without the last column. The need is real; the tint
is not the answer the design system already gives: status colours are
data, not chrome (convention 12), and DECISIONS 2026-08-03 already ruled
out row tints for this list.

Settled rows (paid, cancelled, credited) now recede to muted text as a
whole row. Open rows keep the foreground and the overdue chip stays the
one marker on its row, so three row classes read at a glance with zero
new colour: grey is done, dark is waiting for money, dark with an ochre
chip is late. Works in dark mode and the brand themes for free and never
stacks with the hover or selection tints.

The tone lives in a pure lib helper (invoiceRowTone, tested) and one
Record in the page maps tone to class; a row tint or a left-edge bar is
a three-string swap there if the founder prefers colour.

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

* docs(decisions): record the first-principles choices of the 2026-09-06 evening issue batch

Ten lines for #2215 #2351 #2218 #2220 #2312 #2184 #2293 #2239 #2331 #2332
(PRs #2353 to #2362), carried by this PR so the other nine branches do not
all touch DECISIONS.md.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:24:28 +02:00
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

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

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00
0069a3f59a feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran

Skatteverket decides per begäran but pays everything it decided that day in
one transfer, so the bank row often equals no single open begäran and the 1:1
matcher from #2271 stayed silent; the settle service then refused the amount
and the dialog told the user to split the transaction by hand.

The candidate is now the exact covering set of 1..4 open begäran whose
expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts,
over the existing findExactCoveringSet, ambiguity-refusing). It is computed
at read time from the open pool (inbox page, worklist, ingest), no hint
column. Confirming books ONE voucher (debit 19xx, one 1513 credit per
begäran) through the same writer, marks every begäran paid and links the row
once; a bundle is always booked at exactly the decided sums.

- match-rot-rut-payout accepts request_ids (1..10) beside request_id
- settleRotRutPayoutRequestSet shares the single path's tail
- createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged)
- inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set
- new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set

Closes #2239

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

* fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle

The bundle path booked every leg as fully paid and mirrored requested_amount
onto every begäran's items, while the single path completes a begäran only
when the leg covers requested_total and otherwise leaves it partially_paid
for manual handling. A begäran Skatteverket decided at less than requested
is a legitimate bundle member (its leg is the beslut, the exact-sum rule is
unchanged), so the set path now computes fullyPaid per leg exactly like the
single path, passes it to the shared attachSettlementVoucher, and mirrors
only the fully paid legs; sibling hints are still cleared for every settled
begäran, which carries a voucher and is no longer matchable either way.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:13:53 +02:00
6a5fd6cd00 feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)
A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:05:43 +02:00
3b9daf2606 fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the
  payout RPC applies, so "Jakob" and "jakob " are one person with one
  Att göra row and one exact-amount match.
- The inbox pages through every registered claim (fetchAllRows) before
  pairing, so a long backlog can never understate a person's debt.
- A foreign receipt's VAT field is locked at 0 and 0 is what is submitted.
- Transport failures in the one-click and picker confirms show the
  destructive toast instead of failing silently.
- The open-claims flag is set only after the stale-fetch guard, and a
  payout match decrements the inbox count like every other row exit.
- Test: reset the live-link mock before the bank_line junction case.
- Wording: "Återbetalning av utlägg".

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:18 +02:00
7448490fb7 fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298)

The invoice-to-verifikat link is written on the invoice side only
(invoices.journal_entry_id, invoice_payments.journal_entry_id), while the
missing-underlag predicate and the periodisk sammanstallning resolved the
invoice from the entry's own source columns. A SIE-imported sale matched to
its invoice afterwards therefore kept warning "Underlag saknas" and was left
out of the EU sales list, although the account-based momsdeklaration showed
it and the verifikat page already listed the invoice as its underlag.

- verifikat_without_documents / transactions_without_documents: customer-
  invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row;
  new migration 20260906135702, pinned by a pg-real test.
- getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used
  by the journal-list filter and bulk exempt, /api/documents/counts (new
  invoice_references map) and the transactions list; the push cron mirrors
  it with its global reads.
- Journal list: no "Underlag saknas" chip for a covered entry, matching
  the engine's own invoice rows and the verifikat detail page.
- Periodisk sammanstallning: entries fetched by their EU-revenue lines and
  attributed through every link (engine source_id, invoices.journal_entry_id,
  invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment
  entries are filed too, which the old source_type filter dropped.

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

* fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review)

- The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron)
  now require an ISSUED invoice: status not in ('draft', 'cancelled'), the
  schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_
  STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the
  pg test pins a draft-linked and a cancelled-payment entry as still missing.
- Periodisk sammanstallning: one verifikat linked to invoices of different
  customers is no longer attributed to the first invoice; it is left out of
  the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT
  naming the voucher, the customer count and the amount. Same-customer
  settlements are filed in full.
- Transactions list: a failed invoice-reference lookup leaves that chunk's
  verdict unknown (no badges) and continues with the remaining chunks instead
  of abandoning them.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:04:30 +02:00
9cb1d105e3 fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)
* fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them

Self-hosted deployments on an OpenAI-compatible provider (or with no AI
configured) still showed every entry point into the tool-loop runtime
behind /api/agent/invoke, which answers 503 there. The capability lived
server-side only (getAiStatus().assistantAvailable); no UI could read it.

Hand the flag to the client through CompanyContext (useAssistantAvailable,
beside the paid-capability gate) and gate each entry point that opens
AgentChat: the bookkeeping page's "Skapa med assistent" and "Med
assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake
and /chat/new?intent=. The floating trigger falls back to general help (the
single-call console runs on any provider) instead of hiding, and AgentChat
itself never fires an invoke without the runtime, so a resumed thread or a
forgotten entry point shows a notice instead of a 503. The Hem checklist's
"Anslut till Claude" step renders only where the assistant runs on Claude
and the mcp-server extension is on. Provider-agnostic AI (ask console,
categorization, extraction) and the server-side 503 are unchanged; on
hosted the flag is true and nothing changes.

Closes #2204

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

* test(ai): use a placeholder that cannot match an Anthropic key shape

The new direct-Anthropic status test assigned a string in the exact
format of a live API key, which trips secret scanners on every run. The
config only reads presence, so any non-empty string exercises the path.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:54:47 +02:00
272d19b287 fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side (#2299) (#2345)
* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side

The mark-paid guard probed merchant_name for the FULL supplier name, so the
row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never
matched and the payment was booked twice (#2299).

- counterpartyNeedle(): first distinctive token of the name (alnum, legal
  forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on
  merchant_name OR description in one .or() per currency sweep; the alnum
  shape is what makes the DSL interpolation safe.
- findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer
  detector; both share the sweep and the scorer. The dashboard route's inline
  copy is deleted; the v1 supplier mark-paid door gets the guard it lacked.
- New match_reason already_booked (row already carries a verifikat, booked
  straight from the bank side): ranked first, carries journal_entry_id, and
  the dialogs, MCP path and pending-operation commit word the remedy as a
  rattelse rather than "link it".
- Customer side gets the same token prefilter and classification.

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

* test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard

The customer detector now issues one .or() counterparty probe per currency
sweep instead of two ILIKE queries, so every queued answer after the guard
was consumed one step early: the aggregate-sweep [] became company_settings,
the settings row hit the entry builder, and two tests saw 500 / the wrong
voucher id. Each guard block now enqueues one probe plus the aggregate sweep;
the 409 tests drop the second-probe entry that is no longer read.

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

* fix(invoices): one logic expression per duplicate-payment sweep, never two or= params

The sweep chain carried two .or() calls (currency clause, then name probe).
postgrest-js appends a query parameter per call, so the client sent or=
twice, and whether PostgREST ANDs a repeated key was never proven in this
repo; had it kept one, the currency predicate would be gone and foreign rows
banded against a kronor figure.

counterpartySweepLogic() now nests both groups under one and() inside a
single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*,
description.ilike.*x*)). The sweep issues exactly one .or() per currency.

Proof at three levels: unit tests pin the helper's string; a fake-fetch test
runs the real postgrest-js builder and asserts exactly one or= search param
per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit
(with an amount_sek that would pass every JS check) and right-currency+miss
rows against a real PostgREST and asserts, for both detectors and both
sweeps, that only the first comes back, from PostgREST's own response.

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

* fix(invoices): name storno as the already_booked remedy, never "makulera"

A posted verifikat is never deleted; it is corrected by a storno entry
(BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the
MCP and pending-operation messages and both UI descriptions now say so:
"vänd en av verifikationerna med storno och koppla underlaget till den som
blir kvar" / "reverse one of the two vouchers with a storno entry and attach
the underlag to the remaining one".

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:53:34 +02:00
82d25b7dee fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar (#2213) (#2344)
* fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar

The Betalningar row on the customer invoice page rendered "Inga
registrerade betalningar ännu" whenever invoice_payments had no row, even
under a header saying Betald / Betalning mottagen / Återstår 0 kr. A
migrated invoice that was paid in the previous system is exactly that
state: the provider migration writes status, paid_amount and paid_at from
the source and nothing else, while invoice_payments is written only by
Accounted's own settlement paths (all fail-closed). "Settled with zero
rows" therefore never means "no payment yet"; it means the payment was
recorded where this ledger never saw it.

lib/invoices/payment-history-gap.ts classifies that state from the data
(no provenance column): settled + zero rows + no posted invoice_paid /
invoice_cash_payment voucher keyed on the invoice renders one line,
"Betald {date}, före migreringen till Accounted" (partial and undated
variants); settled + zero rows + such vouchers (#2019 leftovers) lists
the vouchers in place of the rows, linked to the verifikat; a failed
lookup says the history could not be loaded instead of asserting either.
payment_status_empty is removed from both locales: no reachable state
renders it any more.

Closes #2213

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

* fix(invoices): log a failed payment voucher lookup instead of returning null silently

fetchInvoicePaymentVouchers returned null on a DB error with no trace, so
a failure behind "Betalningshistoriken kunde inte hämtas" was invisible.
Warn with the invoice id and the error code/message (no invoice content),
and assert it in the test.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:50:57 +02:00
b71f2bf425 feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line

The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).

- create_expense_payout_batch(..., p_transaction_id): old signature dropped
  so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
  TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
  outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
  primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes

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

* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma

- "Matcha mot utlägg" in the inbox row menu: pick the person and the
  receipts a transfer covers when the exact-amount pairing missed it. The
  picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
  foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
  from Att göra, the attention resource, suggestions and the picker; a
  payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
  the dialog says so.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:16:51 +02:00
cbe5580886 feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)
An out-of-pocket purchase differs from any other receipt only in the
credit account, so the Underlag pane now asks one question for an
unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu)
and books a privately paid receipt in place through POST
/api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and
the deep link into the two-step wizard. The verifikat editor stays
reachable below as the escape hatch (BFL 5 kap 6-7 §).

The person owed surfaces in Att göra under a new Betala band, one row per
person (lib/worklist expense_payout, counted in the total and exposed to
agents through the attention resource). The Utlägg nav row is gated on
existing claims, the same hybrid gate as Körjournal, since the entry
point for a new utlägg is now the Underlag pane.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:57:14 +02:00
bff44e5757 fix(parties): one "från SCB" note per contact section (#2316)
* fix(parties): one "från SCB" note per contact section, not one under every field

The founder read four tags in a row. Kontaktuppgifter now ends with one
sentence naming the fields the register gave: "E-post, Telefon, adress
och Momsnr från SCB." Nothing else changes; the fill rule and the
equality test behind it are the same.

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

* fix(parties): the customer page's registry note tolerates the loading state

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

* fix(parties): the supplier page's registry note tolerates the loading state

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:53:13 +02:00
0ad83b8d71 feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party

Founder feedback on the first Företagsuppgifter (2026-09-05): the org
number twice, the VAT number twice, the legal name repeating the
heading, and Kontaktuppgifter showing dashes while the block above had
the phone, e-mail and address from SCB.

- After a fetch the register's contact details land on the supplier and
  customer rows that point at the party: an empty field, or one still
  carrying what the register said last time, takes the new value; a
  value a person typed stays. Shown as "från SCB" on the row (by
  equality with the registry fact, no source column).
- Företagsuppgifter becomes one status line (legal form, active or not,
  registrations, a Bolagsverket warning when there is one), industry,
  seat with registration date, and size. Identity stays in the header
  (org number now formatted) and Kontaktuppgifter. The legal name shows
  only when it differs from the row's name.
- lib/parties/registry-summary.ts reads the coded SCB facts once for the
  page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape.
- v1: party_id on supplier and customer list rows and detail;
  ?expand=party on detail embeds identity, the register summary, what
  the ledger has seen and payment identities. MCP: party_id on
  gnubok_list_suppliers/customers rows and gnubok_get_party (by party,
  supplier or customer id). Read-only; the parties resource follows.

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

* chore(parties): regenerate the API skill for the party expansion; tighten the get_party description

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

* chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:33:20 +02:00
MattssonandClaude Fable 5.1 f33628f005 feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along

The preview scored the file's accounts against the BAS reference and said
"matchas mot din kontoplan", so a consultant with a 41-account seeded company
read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap
with a non-empty period was only refused after the mapping step.

- Parse route adds preview.chart (accounts new to THIS company vs already
  present, with a sample) via planChartChanges, and preview.fiscalYear from
  precheckFiscalPeriod: the containment/overlap verdict extracted out of
  ensureFiscalPeriod, which now consumes it, so preview and import cannot drift.
- Preview card renamed to Kontoplan with the counts and the fiscal-year
  verdict (match / create / conflict with the import's own refusal text).
- Review step lists the chart among "Vad händer när du importerar?".
- executeSIEImport reports accountsCreated from the account sync; the result
  grid gets a Konton skapade card.

No import logic changed; no migration.

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

* fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton"

Skeptic pass on #2307 refuted the first cut twice:

- "Skapas vid import" was shown for a #RAR the import then refuses under
  BFL 3 kap. (19 months, non-month-end finish, mid-month start after an
  earlier year). The shape rules move into precheckFiscalPeriod as a fourth
  verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a
  consumer of one verdict, same query order.
- The Kontoplan card counted unmapped sources under "Läggs till" and kept
  listing them after the create button, while the result said 0 created.
  planChartChanges (now client-safe in lib/import/chart-plan.ts) counts
  mapped targets only; the create button moves those accounts from
  "Ej mappade" to "Finns redan" in place.
- The review line claimed existing accounts keep their name unless you opt
  in; the switch defaults to on. Reworded to match.
- Sample names follow the file for identity mappings, as the sync does.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:56:54 +02:00
04898c3178 feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)
Founder test on a real company (2026-09-05): a supplier created from
"Webhallen Oktober · Dataskärmar till kontoret" kept that text as its
name, and the SCB facts fetched for the party were nowhere on the
supplier page.

- Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name,
  org number, VAT number, country, then the SCB facts under one source
  line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one
  action. The registry helpers move out of the dossier into
  RegistryFacts so the three surfaces share them.
- The enrich route makes the registry's legal name the displayed name
  of the party and of supplier and customer rows that still carry the
  party's old name; all-capitals names are set in title case
  (lib/parties/registry-name.ts). Names a person set stay.
- legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps
  its vouchers, so a rebuild attaches the new key instead of offering
  the same company again.
- GET /api/parties/[id] reports whether SCB is configured.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:43:34 +02:00
Joakim Hansson 397a3b9bca feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
2026-09-05 13:36:51 +02:00
MattssonandClaude Fable 5.1 9418de585f fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)
* fix(invoices): say what is missing when an invoice preview cannot be rendered

The PDF route already refuses with a structured envelope that names exactly
what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a
SEK invoice; no IBAN account for a foreign currency) and where to add it.
Two clients threw that away:

- The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa
  faktura) wrapped the envelope's inner object in new Error(), which
  stringified it to "[object Object]" and left only the generic "Kunde inte
  hantera fakturan. Försök igen." fallback. The parsed body now goes to the
  error mapper whole, with the invoice context and status.

- The invoice page's Förhandsgranska navigated a new tab straight to the
  re-render URL, so a 400 showed the raw JSON in that tab. The tab is now
  opened blank inside the click's activation window, the PDF is fetched
  first, and the tab gets the PDF as a blob URL or is closed again with the
  refusal in a toast. The archived delivery copy keeps the direct open.
  Ladda ner on the same page had a fixed "Kunde inte generera PDF" for
  re-render refusals and now maps the body the same way.

Regression test on the mapper covers the exact call shape the two surfaces
use and pins the old mangled shape as the fallback it produced.

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

* fix(invoices): probe the PDF route before opening the preview tab

Resolves the review findings on the first push in one pass.

Skeptic (correctness): the archived-copy branch still called window.open
with 'noopener', which returns null by spec even on success, so every
successful archived preview also fired the "popup blocked" toast (#1613
had the same defect). Both branches now go through openDeferredTab, which
opens with a real handle and severs the opener itself.

Skeptic (regression): serving the re-render as a blob URL lost the
Content-Disposition filename and gave the tab an address that dies on
reload. The route gains ?probe=1, which runs every refusal check and
answers 204 without rendering; the page probes first, shows a refusal as a
toast, and otherwise points the tab at the real inline URL. Filename,
reload and the single render are all kept. The blob URL is gone, which
also settles the compliance swarm's noopener and unrevoked-blob notes and
CodeRabbit's revoke request.

CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled
route cannot leave a blank tab open, and the network-error mapper now
receives the active locale and invoice context.

Tests: route probe (204 without render, same 400 envelope as the render,
unknown value ignored) and the URL helper's probe flag.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:55:45 +02:00
68fee7dbe7 fix(supplier-invoices): fit the list inside the content column (#2262) (#2281)
The Leverantörsfakturor table was 1133px wide at every desktop size while
the content column is at most 960px (max-w-5xl minus px-8) and 948px on a
1280-wide laptop. With nowrap cells every column adds its widest header
or cell to the table's minimum width, so the overflow-x-auto wrapper
scrolled sideways: Leverantör collapsed to its header width (129px) and
the Status chips were cut at the right edge, with the Godkänn column off
screen. Measured with the real page rendered under /sandbox at 1280,
1366, 1440, 1536 and 1920 wide.

The list carried two date columns plus "Kvar att betala" on top of what
the customer invoice list shows, and #2091's always-visible sort control
added ~18px to each of seven headers, which tipped an already tight
budget over the column. Viewport breakpoints cannot help because the
column is capped at 960px regardless of screen size.

- Drop the Fakturadatum column from the list: förfaller is the payer's
  date and the default order, and the invoice date lives in the detail
  view (the customer invoice list has no invoice-date column either).
  The sort comparator keeps invoice_date as its tie-break; only the
  header goes.
- Shorten the sv header "Kvar att betala" to "Kvar": the label was 163px
  for a column whose numbers need ~120px.
- Leave a column-budget comment on the table and one sentence in the
  dry-table design rule, since there is no shared list component to fix:
  every page-level list hand-writes the overflow-x-auto wrapper, and the
  three overflow reports had three different causes.

After the change the table measures 948/960px (equal to its wrapper) with
worst-case data (16-char invoice numbers, seven-digit amounts, two chips
on one row), and Leverantör keeps 142-154px even then.

Fixes #2262


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:18:50 +02:00
c6ca119e73 feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)
* fix(parties): one suggestion per legal person, and a later run may rename an untouched one

Found while walking the queue end to end: two voucher keys naming the same
company ("TIC identity · … The Intelligence Company AB (publ)" and
"Utbetalning leverantörsfaktura …, The Intelligence Company AB (publ)")
became two suggestions and, after Lägg upp, two suppliers; and a suggestion
made before the legal-form anchoring kept its sentence-long name for good,
because apply_party_suggestions never touched a name.

- Suggestions whose display name is anchored on a legal form read out of
  the voucher text (name_anchored) are grouped: one item, both keys as
  aliases, stats summed. Such a name also attaches to an existing party
  called exactly that, legal form included, unless an org number on either
  side says otherwise. Registered company names are unique in Sweden; a
  bank memo never groups or attaches by name.
- Migration 20260904030000: apply_party_suggestions renames a suggestion
  nobody has touched (no decision, no user or registry fact) to an anchored
  name from a later run, and reports 'renamed'. Confirmed and decided
  parties keep their names.

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

* fix(parties): read legal_name for exact-name attach; say a row is foreign instead of offering SCB

next build: ExistingParty had no legal_name, so the exact-legal-name index
did not compile. The query now selects it.

Queue rows whose voucher text places the company abroad show
"Utländskt bolag (Nederländerna), finns inte i SCB" instead of a search
that cannot succeed; the promote dialog counts them separately from rows
that merely lack an org number; the dossier shows the country.

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

* fix(parties): carry country on the dossier row

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

* feat(parties): one review list for SCB matches, a model reading for bank memos, refresh demoted

- Review list ("Hitta org.nr (n)" in the queue toolbar): every suggestion
  SCB could hold but that lacks an org number is asked for, one row at a
  time under SCB's rate limit; rows with exactly one active match are
  shown ticked and approved in one click, the rest keep the per-row
  picker. Nothing is written before the click.
- Model reading (lib/parties/ai-name.ts, through getAiService): when the
  rules find no legal form or country in the texts, one call reads the
  counterpart out of the bank memo; kept as a 'model' fact, shown as
  "Läst ur verifikatet", used as the query, never as a hard key. On
  demand only, never when the queue builds.
- "Uppdatera förslag" moves from the page header to a ghost button in the
  toolbar: the queue builds itself now.

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

* fix(parties): review list passes the dialog overflow guard; plural for match counts

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

* fix(parties): gate the model reading on the company's AI capability

Same gate as every other model call on company data: the capability the
company holds by plan and can switch off. No call, no fact, no reading
without it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:01:10 +02:00
d463ee1c87 feat(supplier-invoices): payment-queue sections and a grouping picker (#2102)
* feat(supplier-invoices): payment-queue sections and a grouping picker

The list groups into Väntar på betalning / Betalda och avslutade
sections by default so the payment queue is never buried; a toolbar
picker switches grouping to supplier, month, or none, written back to
the URL as ?group=. The tri-state column sort governs order inside
each group, and with no sort active the status sections run newest
first. Both message catalogs carry the new strings.

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

* fix: group by supplier_id, not display name (mirrors #2101 review)

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

* fix(supplier-invoices): address review: shared helper, API order kept, queue includes partially_paid

- keep CHECKBOX_REVEAL_CLASS and useRangeSelect from main (the PR had
  reverted #2093 and the region now carries #2117)
- default the grouping picker to 'none'
- render sections from the shared lib/lists/group-rows.ts helper, the same
  implementation #2101 uses, instead of a second copy of the bucketing
- drop the byNewestFirst re-sort: for a payment queue the API's
  forfallodatum-ascending default is the order that matters, so grouping
  no longer changes it
- isAwaitingPayment now mirrors PAYABLE_STATUSES and includes
  partially_paid, so a partially paid invoice stays in the queue
- colSpan follows the column count: canWrite ? 9 : 8
- replace the banned em dash placeholders with an UNKNOWN_GROUP_KEY
  sentinel and a translated label
- section headers carry data-no-stagger, and the header row uses the same
  index-based prevKey shape as #2101 instead of an IIFE

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

* fix(supplier-invoices): flat is the URL-less default; range select walks the grouped order (review)

Only 'none' owns the URL-less state so a chosen grouping survives reload. Shift-click ranges now index the sectioned order the table renders instead of the pre-grouping sort, as useRangeSelect requires.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:54 +02:00
304b50b5eb feat(invoices): grouped sections and a grouping picker on the customer invoice list (#2101)
* feat(invoices): grouped sections and a grouping picker on the customer invoice list

Default view groups rows into Utkast / Väntar på betalning / Betalda
och avslutade sections; a toolbar picker switches grouping to customer,
month, or none, written back to the URL as ?group= so views stay
shareable. Column sorting applies within each section and cycles
asc / desc / default so an applied sort can be released; paging and the
detail pager follow the rendered group order. Both message catalogs
carry the new strings.

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

* fix: group by customer_id, not display name (review)

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

* fix(invoices): address review: shared bucketing helper, flat default, no #2093 revert

- keep CHECKBOX_REVEAL_CLASS and useRangeSelect from main: the PR had
  re-inlined the old hidden-until-hover classes, reverting #2093, and the
  same region now carries #2117's shift-click range selection
- default the grouping picker to 'none': sections on the most-used list
  page are a design change, so grouping stays an explicit choice
- extract the ~90 lines of bucketing into lib/lists/group-rows.ts, a pure
  helper with vitest coverage that both list pages now render from
- derive statusGroupOf from matchesListTab so the sections and the tabs
  cannot drift apart
- replace the banned em dash placeholders with an UNKNOWN_GROUP_KEY
  sentinel and a translated 'Saknas' label
- section headers carry data-no-stagger so they skip the row animation

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

* fix(invoices): flat is the URL-less default; drop the sort cycle (review)

Picking Status stripped the group param while the initialiser falls back to the flat list, so the choice was lost on reload; now only 'none' owns the URL-less state. The header sort returns to main's two-state toggle (DECISIONS 2026-08-11).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:49 +02:00
4eb1626129 feat(salary): recurring payroll lines per employee (#2042) (#2044)
* feat(salary): recurring payroll lines per employee (#2042)

A standing per-employee payslip row derived into every salary run inside
its validity window, e.g. a benefit-bike bruttolöneavdrag of -670 kr/month.
Mirrors the employee_benefits pattern end to end:

- employee_recurring_lines table with RLS, audit + updated_at triggers, and
  a salary_line_items.source_recurring_line_id back-link; amount sign and
  account format enforced by CHECKs
- run-calculation step 8d3 derives rows with flags computed from the item
  type (gross deductions reduce tax + AGA bases, net deductions post-tax);
  derived rows are excluded from the manual-line set like benefit rows
- CRUD routes under /api/salary/employees/[id]/recurring-lines with the
  same 401/403/404/400 contract as the benefits routes
- EmployeeRecurringLinesPanel on the employee page, sv/en strings
- registered in the BFL full-archive export

Closes #2042

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

* fix(salary): address #2044 review: feed recurring rows to the engine, guard deletes

- Derived recurring rows are now appended to the calculateSalary lineItems
  set: they were inserted into salary_line_items but excluded from the
  in-memory calculation, so a recurring deduction never affected the payslip
  math (CodeRabbit, major).
- DELETE deactivates a line that has derived rows instead of hard-deleting:
  ON DELETE SET NULL would turn a draft run's derived row into an apparent
  manual row that recalculation keeps forever; deactivation preserves the
  provenance link and lets the next recalculation drop the draft rows
  (CodeRabbit, major). The panel hides inactive lines.
- POST employee lookup uses maybeSingle and answers 500 on lookup failure,
  404 only on zero rows.
- Panel: try/finally releases loading/submitting on network failure, and a
  request sequence guard stops a stale load from overwriting a newer list.

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

* fix(migrations): move employee_recurring_lines off 20260830140000, which upstream now occupies

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

* fix(migrations): bind employee_id to company_id with a composite FK (review)

The dimensions pattern: UNIQUE (id, company_id) on employees plus a
composite FK, so RLS company scoping cannot be sidestepped by pointing
a recurring line at another company's employee (IDOR, CWE-639).

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

* fix(salary): address review: deductions only, race-free delete, engine and pg tests

Review round on #2044:

- Blocker: recurring 'other' additions removed from the whitelist, the
  migration CHECK and the panel. calculateSalary only treats
  ADDITION_TYPES as additions, so a recurring taxable addition rendered
  on the payslip without entering gross, tax, AGA or AGI. Re-add only
  together with engine support (recorded in DECISIONS.md).
- Delete race: salary_line_items.source_recurring_line_id is now NO
  ACTION instead of SET NULL; the DELETE route deletes first and falls
  back to deactivation on 23503, so a deletion racing a concurrent
  derivation can never orphan a derived row into an apparent manual row.
  NO ACTION defers to statement end, so company-deletion cascades are
  unaffected.
- Correction runs copy source_benefit_id / source_recurring_line_id, so
  recalculating a correction no longer derives the copied rows a second
  time (pre-existing for benefits, now pinned).
- Engine tests: gross_deduction_other through calculateSalary asserts
  gross, taxable income and avgifterBasis drop while the semester base
  stays; net_deduction_union only moves the paid-out net.
- pg-real tests for the new table: RLS membership, composite FK
  cross-company refusal, deduction-only CHECKs, and the NO ACTION
  back-link blocking deletes of derived-into lines.
- Nice-to-haves: POST rounds the stored amount to ore, the redundant
  single-column employees FK is dropped (composite carries the cascade),
  the schemas.ts comment references the real migration version, and the
  panel explains the validity-window semantics (payment date, bounds
  inclusive, no proration).
- Rebased onto main; the phantom-columns ceiling re-measured at 395 on
  the merged tree.
- DECISIONS.md records the vacation-basis judgment call (semester base
  not reduced by recurring gross deductions).

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

* fix(salary): gate recurring-line writes on the writer role, 404 unmatched deletes

Two findings from the 2026-09-02 review round:

- Superagent P1: the write policies were membership-only, so a read-only
  viewer could write recurring payroll deductions straight through
  PostgREST, bypassing the route's requireWrite. The table now carries
  aa_enforce_company_writer_role, the same gate 20260902093000 attaches
  to every company-scoped table (it also fires inside SECURITY DEFINER
  bodies, where RLS does not apply). The migration is re-versioned to
  20260902140000 so the function exists when a fresh database replays
  the folder in order.
- CodeRabbit: a filtered DELETE reports no error when nothing matches,
  so an unknown or cross-company line answered 200 deleted: true. The
  delete now selects the removed row and answers 404 when it is null.

Tests: pg-real asserts a viewer is refused insert, update and delete
with 42501 while the row survives unchanged, plus a non-member case; the
route tests pin the 404. 896 salary tests green, rebased on main.

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

* test(salary): pin the recurring-line payload column sets

Answers the phantom-column ceiling finding with scoped assertions rather
than a bare ceiling raise: the PATCH route test now asserts the exact
writable column set, and the comment records that the pg-real test covers
the derived-row shape against the real table. Making the PATCH payload a
literal would turn a partial update into last-write-wins, which is why
the shape stays unresolved.

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

* fix(salary): round recurring line amounts with roundOre

check:guards naive-ore-round ratchet: the derived recurring row used Math.round(x * 100) / 100 (baseline 615, +1); roundOre is already imported in run-calculation.ts.

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

* fix(migrations): guard the employees unique-key add against #2145 merge order

#2145 (expense claims) also adds employees_id_company_id_key. Wrap this
migration's ADD CONSTRAINT in an idempotent DO block so whichever of the
two PRs merges second does not fail on a duplicate constraint.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:45 +02:00
MattssonandClaude Fable 5.1 50b6299699 feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row

A ROT/RUT invoice is stored with remaining_amount net of the deduction, so
once the customer pays it flips to paid and drops out of the matchable set.
Skatteverket's payout for the 1513 share then lands as an income row with no
candidate: the only clearing path was a headless settle endpoint that never
linked the bank row.

The candidate is the payout request (one lump sum per begäran, possibly
covering several invoices), modelled exactly like the supplier-invoice hint:

- migration 20260904020000: transactions.potential_rot_rut_payout_request_id
- pure matcher (exact amount vs decided_total ?? requested_total, boosted
  when Skatteverket is named, ambiguous when two requests share the amount)
- hint written at bank ingest and by batch-match-invoices; cleared by the
  link and reconciliation paths and by clearSettledInvoiceSuggestions
- shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing
  settle route and the new POST /api/transactions/[id]/match-rot-rut-payout,
  which books debit 19xx / credit 1513 and links the row in one call
- transactions inbox pill, own confirm dialog listing the covered invoices,
  manual fallback section in the invoice picker, worklist and Att göra rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers

Skeptic findings on 6aa7b2e5c:
- a bank row larger than the begäran was booked in full, driving 1513 into
  a credit balance and rewriting decided_total to the bank amount: refuse
  amount > decided_total ?? requested_total in the service and block the
  dialog's confirm with the reason
- two concurrent settles could both attach and credit 1513 twice: the
  request update now locks on settlement_journal_entry_id IS NULL and the
  loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id
- a row with a stale (reversed) journal_entry_id passed the route guard but
  always lost the null-only link CAS: the route forwards the pointer it read
  and the service locks on that value, as link-journal-entry does
- the pinned underlag on the bank row now propagates onto the voucher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran

CodeRabbit findings on a93dc46b8, one batch:
- picker and dialog only offer a begäran to SEK rows (the route refuses
  other currencies, so the manual flow no longer dead-ends)
- a voucher-less `paid` request (beslut recorded via PATCH, money not yet
  booked) is matchable; settled means a settlement voucher exists
- ingest and batch-match check the hint update's error before draining the
  pool or counting the match
- the invoice.match_confirmed payload clears the payout hint like the row
- migration 20260904021000: partial unique index on journal_entries
  (company_id, source_id) for live rot_rut_payout entries, so two racing
  settles cannot both book a voucher; pg test included

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:33:41 +02:00
82859d01db feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)
* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones

The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.

- lib/parties/name-extract.ts: name candidates read out of the text,
  anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
  ...) and on country words, plus EU VAT numbers. Every candidate is a
  substring of the text; foreign forms and countries mark the candidate
  as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
  text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
  the voucher texts are stored as a ledger fact for the picker, the
  country is stored when the text says, and a single foreign VAT number
  in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
  cleaned head last, at most three queries, stopping at the first hit;
  no SCB call when the best reading is foreign, the response says which
  company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
  täcker bara svenska företag." with a hint to save by name and VAT
  number; alternate readings offered as one-click searches when the
  first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
  not seen, not only on a first visit; the toast only appears when
  something was created.

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

* fix(parties): take a text-derived VAT number only on the expense side

A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:43:41 +02:00
8265b5d166 feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search

After a SIE migration or verifikat backfill, customer invoices exist only
as journal entries: the invoice list, kundreskontran, /api/invoices, v1
invoices.list, and MCP list_invoices all looked complete while silently
omitting everything before the register's first invoice (user report:
two invoiced fees nearly re-invoiced as "uninvoiced").

- lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest
  register invoice; flags posted non-invoice-engine AR verifikat
  (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so
  manual/API backfills are caught too.
- Invoice list page: one attn line disclosing the boundary (sv+en).
- Kundreskontra: register_coverage in the report payload, rendered in the
  summary card and as an explanation under "Ej avstamd".
- /api/invoices GET: invoice_register_coverage in the response.
- v1 invoices.list: meta.coverage + registry pitfall documenting it.
- MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on
  the first page, pointing agents at gnubok_query_journal.
- Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross
  amounts with sv-SE formatting, alongside number/customer matching; a
  known net amount like 14 000 now finds the 17 500 kr row.

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

* fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill

Skeptic + CI findings folded into one pass:

- Coverage probe: a failed AR lookup now degrades to UNKNOWN
  (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete".
- Probe driven from journal_entries (company-indexed) with the AR line
  condition as an inner embed, instead of the lines-table-with-embed-filters
  shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts).
- DEBIT-only 1510/1513 lines; excludes every invoice-engine source type
  (invoice_created, invoice_paid, invoice_cash_payment, credit_note,
  reminder_fee, rot_rut_payout, storno, correction): an advance payment
  crediting 1510 or a re-dated rattelse of an engine entry no longer flags.
- covers_from ignores drafts so a backdated draft cannot move the boundary.
- Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR
  debits existing IN the reconciled period (new
  ARReconciliationResult.pre_register_ar_in_period): prior-period migration
  history cannot explain this period's difference and must not excuse a
  real felbokning. Wording no longer says "snarare an felbokning".
- MCP coverage_note states the earliest register invoice date rather than
  claiming the register "covers" from it.
- Amount search compares magnitudes so credit notes (negative totals) are
  findable; "-17500" parses; null amounts never match "0".
- skills/accounted-api regenerated from the registry (apiskill:check).

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

* chore(api-skill): regenerate accounted-api skill after merging origin/main

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

* fix(invoices): round-2 review fixes for register-coverage disclosure

- covers_from now anchors on real invoices only (document_type='invoice',
  non-draft): proformas/delivery notes cannot move the boundary.
- INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers
  (invoice-entries, reminder-fee, rot-rut, storno-service) so a future
  source_type cannot silently become false pre-register evidence.
- Kundreskontra guidance names both 1510 and 1513.
- MCP gnubok_list_invoices outputSchema declares invoice_register_coverage
  and coverage_note.
- v1 reports.ar-ledger documents data.register_coverage; invoices.list
  example made internally consistent; api skill regenerated.

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

* fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget

The expanded schema from the round-2 review pushed tools/list to 61 726
tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The
ceiling is policy, not a baseline to bump: the description already tells
agents to read invoice_register_coverage/coverage_note, and paginatedSchema
has no additionalProperties:false, so the fields stay schema-valid.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 09:39:14 +02:00
91e2c66afc fix(parties): readable suggestions from assistant vouchers, auto-build queue, SCB fetch after promotion (#2259)
* fix(parties): readable suggestions from assistant-written vouchers, auto-build queue, SCB fetch after promotion

Live feedback on a real company (2026-09-03): the queue showed 35 one-off
suggestions with sentence-long names, wide empty rows, a "Hämta förslag"
step nobody could predict, no SCB fetch after promotion, and an empty
supplier created from a Finansinspektionen fee line.

- ledger_key v2 (migration 20260904002000): keep the counterpart head of
  "<counterpart> · <note>" descriptions, drop bank method tokens and long
  references before normalising; JS mirror in lib/parties/ledger-key.ts
  with shared LEDGER_KEY_CASES. Suggested parties nobody has touched are
  rebuilt under the new keys (repair in the same migration).
- apply_party_suggestions attaches by VAT number too, so ledger keys with
  a VAT number but no org number reach existing roles.
- Queue: fixed name/reason column widths, inline "Hitta i
  företagsregistret" for rows without an org number.
- Page: builds the queue automatically on first visit when nothing has
  been suggested yet; after promotion, fetches SCB facts for every
  promoted legal person (spaced under the 10 calls/10 s limit) and fills
  the role's VAT number; confirm dialog says how many rows lack an org
  number.
- Classifier: more authorities (Finansinspektionen, Arbetsförmedlingen,
  Pensionsmyndigheten, ...) and fee words (registreringsavgift,
  tillsynsavgift, ...) so fee lines stop becoming suppliers.

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

* fix(parties): scope the suggestion repair to keys the new ledger_key no longer produces

Superagent flagged the repair DELETE as global. It now only removes
untouched pipeline suggestions that no posted voucher of the company maps
to under the new function; suggestions whose key is unchanged stay.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:19:12 +02:00
22b98e0a3b feat(parties): fetch registry facts from SCB into the dossier, with a picker for parties without an org number (#2258)
* feat(parties): Kontakter register, suggestion queue, dossier and merge

Phase 1's two surfaces on top of the parties substrate:

- /parties page: one list with the five-way switch (Alla, Kunder,
  Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all
  period picker, and at most one attention line. Confirmed rows show
  roles as muted text, rhythm, underlag, dominant account and money.
  Observed rows are computed and never stored; a generic band keeps
  unattributed spend visible.
- Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk
  confirm behind one dialog, dismiss on hover, undo on the toast.
- Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and
  identities with source and count), Underlag och verifikat, Historik.
- Merge dialog with a visible, swappable survivor and undo.
- API: GET /api/parties, GET /api/parties/[id], POST suggest, decide,
  decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests).
- Migration 20260903090000: decide_parties snapshots the reason it
  clears; undo_party_decisions reverses confirm/dismiss within 30 days;
  decision kind 'undo'.
- The pipeline runs after SIE import and provider migration (non-blocking)
  so a migrant's register is full on arrival.
- Nav entry under Register; sv/en strings.

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

* fix(parties): pass explicit interpolation values to next-intl

next build's type check rejects a typed interface where the translator
wants an index-signature record.

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

* fix(parties): retry label on the load-failed state

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

* fix(parties): hard keys for companies without org number, readable names, look-alikes at read time

- get_ledger_key_evidence dropped every document for a company whose own
  org number is NULL (the self check compared against NULL). Replaced in
  20260903100000 with a coalesced comparison; pg test covers it.
- Display names come from the printed name on documents, otherwise from
  the voucher text with the AP/AR prefix and supplier number removed.
- Look-alike parties (same core, or one core extending the other by whole
  words: Fortnox / Fortnox Finans) are detected when the register is read,
  never stored, and feed the Dubblett? chip and the merge dialog.
- Queue shows Intäkt beside Kostnad; dossier hides zero money rows and
  formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no
  synchronous setState inside effects.

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

* feat(parties): link every new supplier and customer to a party on write

The backfill covered the rows that existed on 2026-09-02; 108 rows
created since had no party and never reached the register. A BEFORE
INSERT/UPDATE trigger on customers and suppliers now calls ensure_party
on every write path at once: find-or-create by org number inside the
company, never by name; a private customer gets a kind=person party
without any number; a nameless row stays unlinked; a foreign party id is
refused with the same error as the composite foreign key; a link to a
merged party follows the chain to the survivor; the clear that ON DELETE
SET NULL performs is kept. ensure_party lets the trigger act for the
row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The
migration also links the rows created since the backfill.

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

* fix(parties): dossier hides dismissed parties and follows merges to the survivor

The register hid archived parties while the dossier still served them by
id, and a merged party's dossier pointed at a dead row. Superagent P2 on
#2206; three unit tests.

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

* chore(parties): move the role-link migration past main's 20260903110000

Two files with one version would collide in schema_migrations.

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

* feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun

Founder decision after the walkthrough: users know two words. The page
becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen'
beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer
views go. Each suggestion shows what it becomes (Blir), read from the
ledger side and changeable per row; confirming calls promote_parties,
which creates the supplier and/or customer row from the party's facts,
never a duplicate, and is undoable for 30 days through
undo_party_promotions (the created rows are archived, the party returns
to the queue). Leverantörer and Kunder carry the one attention line that
leads here. The dossier offers Lägg upp som leverantör / som kund.

Migration 20260903130000, 5 pg tests, route and unit tests updated.

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

* fix(parties): write bankgiro and plusgiro the way the supplier form does

Identities are stored as digits; suppliers carry 5317-0900.

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

* feat(parties): fetch registry facts from SCB into the dossier

SCB granted API access today (certificate + password, layouts Je and
Ae). This adds the first registry enricher of phase 3:

- lib/parties/scb: config from env (SCB_API_CERT_PFX_BASE64,
  SCB_API_CERT_PASSWORD), an mTLS transport on node:https, the mapping
  of every documented Je variable to a labelled fact, and a client whose
  wire format sits in one file because SCB replaces the API this month.
  Legal persons only: a sole trader's org number is a personnummer.
- Migration 20260903150000: record_party_facts(company, user, party,
  source, facts, fetched_at) refreshes unchanged values, supersedes
  changed ones, never touches other sources. pg test.
- POST /api/parties/[id]/enrich: 503 when not configured, 400 for a
  sole trader, 502 when SCB fails, fills an empty legal name. 7 tests.
- Dossier: 'Hämta uppgifter' button (gated on configuration) and the
  registry rows with 'SCB · datum' as their source line.
- scripts/scb/discover.ts prints the live variable list, code tables and
  one lookup so the request shape is checked against the real API.

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

* fix(parties): SCB client on the live wire format, mapper on the real Je row

Verified against the API on 2026-09-03: an identity lookup is one filter
(Variabel 'OrgNr (10 siffror)', Operator ArLikaMed) without status keys,
and the row carries '<name>, kod' beside SCB's own text. The mapper now
reads those columns, prefers SCB's text, and adds turnover band, seat
names and Skatteverket registration. The AB Volvo row is the fixture.

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

* fix(parties): registry legal name outranks the document one, never a person's

Survivorship from the plan: user > registry > document. The dossier's
legal-name row now carries 'SCB · datum' when the registry is the source.

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

* fix(parties): VAT number from the moms flag, one primary action, one source line

Founder review of the SCB dossier:
- A Swedish company registered for moms has VAT number SE + org number
  + 01 by construction, so the registry's moms flag yields the number;
  it fills an empty vat_number on the party and shows in the Momsnr row
  instead of 'Saknas'.
- The 'Registrerad hos Skatteverket' row said nothing (true for every
  legal person) and is gone.
- Five buttons became one primary (the role the ledger suggests) and a
  menu with the rest; the per-row 'SCB · datum' notes became one group
  line 'Från SCB · hämtat datum'.
- A postal-code-only address (large companies) is labelled as such.

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

* fix(parties): do not repeat the county when it equals the municipality

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

* feat(parties): SCB picker for parties without an org number

'Hitta i företagsregistret' in the dossier menu opens a picker: SCB is
searched on the party's name (prefix first, contains as fallback, counts
before rows, capped at 25, natural persons and estates excluded, active
companies first). The user chooses; the org number is recorded as a fact
with source 'user' and set on the party, then the normal fetch runs, so
every later fetch is by number. A number another live party holds is
refused with a pointer to it. One match is still shown, never auto-picked.
The transport retries once on a dropped connection (seen live).

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

* fix(parties): a picked org number shows in the queue's reason and counts as a hard key

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

* fix(parties): SCB search tightened after a batch of real supplier names

Twenty-five prod supplier names and twenty org numbers across every
legal form went through the search and the lookup:
- total is what the picker can offer, not SCB's raw count (Eismann
  counted one row and offered none, a natural person);
- foreign legal forms stay in the query: they are part of the registered
  name and dropping them floods (Schmidt GmbH became 167 Schmidts);
- a fusion or delning in progress is no longer a warning (Fortnox AB and
  Avanza Bank trade normally under 'Fusion pågår'); distress and
  disappearance codes still are.

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

* chore(parties): move the four queue migrations past main's 20260903170000

Main merged 20260903120000_skattekonto_transactions_realtime_publication
with the same version as the role-link trigger; the preview database
refused the duplicate key. All four now sit after main's newest so the
set applies in one ordered run on prod.

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

* chore(parties): move record_party_facts after the queue migrations

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

* chore(parties): move record_party_facts to a version after tonight's collisions

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:16:59 +02:00
34b677b02c chore(ui): retire the Building2 icon app-wide (#2235)
Founder request from the register walkthrough. Suppliers (nav, command
palette, empty state) use Truck; company and company-scoped surfaces
(active company badge, invite, home signpost, SIE preview, template
scopes, TIC workspace and its manifest) use Briefcase; the two bank
contexts use Landmark. The extension icon resolver no longer maps
Building2; the generated sector definitions follow the manifest.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:02:14 +02:00
b996da60ee feat(parties): Förslag från bokföringen, confirmed straight into Leverantörer and Kunder (#2206)
* feat(parties): Kontakter register, suggestion queue, dossier and merge

Phase 1's two surfaces on top of the parties substrate:

- /parties page: one list with the five-way switch (Alla, Kunder,
  Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all
  period picker, and at most one attention line. Confirmed rows show
  roles as muted text, rhythm, underlag, dominant account and money.
  Observed rows are computed and never stored; a generic band keeps
  unattributed spend visible.
- Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk
  confirm behind one dialog, dismiss on hover, undo on the toast.
- Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and
  identities with source and count), Underlag och verifikat, Historik.
- Merge dialog with a visible, swappable survivor and undo.
- API: GET /api/parties, GET /api/parties/[id], POST suggest, decide,
  decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests).
- Migration 20260903090000: decide_parties snapshots the reason it
  clears; undo_party_decisions reverses confirm/dismiss within 30 days;
  decision kind 'undo'.
- The pipeline runs after SIE import and provider migration (non-blocking)
  so a migrant's register is full on arrival.
- Nav entry under Register; sv/en strings.

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

* fix(parties): pass explicit interpolation values to next-intl

next build's type check rejects a typed interface where the translator
wants an index-signature record.

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

* fix(parties): retry label on the load-failed state

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

* fix(parties): hard keys for companies without org number, readable names, look-alikes at read time

- get_ledger_key_evidence dropped every document for a company whose own
  org number is NULL (the self check compared against NULL). Replaced in
  20260903100000 with a coalesced comparison; pg test covers it.
- Display names come from the printed name on documents, otherwise from
  the voucher text with the AP/AR prefix and supplier number removed.
- Look-alike parties (same core, or one core extending the other by whole
  words: Fortnox / Fortnox Finans) are detected when the register is read,
  never stored, and feed the Dubblett? chip and the merge dialog.
- Queue shows Intäkt beside Kostnad; dossier hides zero money rows and
  formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no
  synchronous setState inside effects.

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

* feat(parties): link every new supplier and customer to a party on write

The backfill covered the rows that existed on 2026-09-02; 108 rows
created since had no party and never reached the register. A BEFORE
INSERT/UPDATE trigger on customers and suppliers now calls ensure_party
on every write path at once: find-or-create by org number inside the
company, never by name; a private customer gets a kind=person party
without any number; a nameless row stays unlinked; a foreign party id is
refused with the same error as the composite foreign key; a link to a
merged party follows the chain to the survivor; the clear that ON DELETE
SET NULL performs is kept. ensure_party lets the trigger act for the
row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The
migration also links the rows created since the backfill.

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

* fix(parties): dossier hides dismissed parties and follows merges to the survivor

The register hid archived parties while the dossier still served them by
id, and a merged party's dossier pointed at a dead row. Superagent P2 on
#2206; three unit tests.

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

* chore(parties): move the role-link migration past main's 20260903110000

Two files with one version would collide in schema_migrations.

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

* feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun

Founder decision after the walkthrough: users know two words. The page
becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen'
beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer
views go. Each suggestion shows what it becomes (Blir), read from the
ledger side and changeable per row; confirming calls promote_parties,
which creates the supplier and/or customer row from the party's facts,
never a duplicate, and is undoable for 30 days through
undo_party_promotions (the created rows are archived, the party returns
to the queue). Leverantörer and Kunder carry the one attention line that
leads here. The dossier offers Lägg upp som leverantör / som kund.

Migration 20260903130000, 5 pg tests, route and unit tests updated.

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

* fix(parties): write bankgiro and plusgiro the way the supplier form does

Identities are stored as digits; suppliers carry 5317-0900.

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

* chore(parties): move the four queue migrations past main's 20260903170000

Main merged 20260903120000_skattekonto_transactions_realtime_publication
with the same version as the role-link trigger; the preview database
refused the duplicate key. All four now sit after main's newest so the
set applies in one ordered run on prod.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:49:25 +02:00
2c3ffaf51c feat(invoices): offer proforma where the invoice is created (#2217) (#2254)
Proforma existed (document_type 'proforma', its own conversion path and
views) but the only way to pick it was the collapsed Förval panel inside
the editor. A user coming from Fortnox looked for it next to "Ny faktura",
did not find it, and concluded the feature was missing.

"Ny proformafaktura" is now an entry in the create split button on
/invoices, driven by ?proforma=1 exactly like ?quote=1 drives "Ny
offert": the URL opens NewInvoiceDialog with the proforma type
preselected, the dialog's accessible title says so, and closing the
dialog clears the param. Keys added to sv and en.

Closes #2217


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:56:14 +02:00
5070041028 fix(import): name the IB-imbalance cause and offer the manual opening-balance path (#2082) (#2252)
The SIE preview's "Ingående balanser balanserar inte" warning was a dead
end: it said the diff would be booked to 2099 and offered an acknowledge
checkbox, nothing else. A new user parsed her SpeedLedger file five times,
never reached execute, and emailed support to ask whether IB can be
entered by hand (it can, the flow just never said so).

The warning now names the usual cause in plain Swedish (föregående års
resultat never transferred to eget kapital; SpeedLedger parks it on 9030
/9031), spells out the two ways forward, and offers a button straight into
the manual "Ingående balanser" wizard (the CSV/Excel wizard's default
entity). The acknowledgement path is unchanged. Copy stays hardcoded
Swedish like the rest of the import wizard.

Not done: letting the SIE import skip IB when a period already has them
(the import refuses such a period today), and running
findUntransferredResults at parse time so the preview can name the amount.

Closes #2082


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:55:09 +02:00
MattssonandClaude Fable 5.1 3918ff6620 fix(customers): make country ISO-2 everywhere and check it against the customer type (#2241)
* fix(customers): make country ISO-2 everywhere and check it against the customer type (#2025, #2028)

customers.country and suppliers.country were read as ISO codes by the
periodisk sammanstallning (SKV 5740), Peppol and the provider importers but
written as English names by the customer form and the v1 API, so a correct
German customer produced GERMANY811234567 in the SKV file plus two false
warnings, and an EU customer saved with land Sverige got reverse charge with
nothing objecting until after the invoice was sent.

- lib/vat/country-codes.ts: one helper that normalises codes and the
  Swedish/English names the writers used to store, the country-vs-type
  rule (swedish_business = SE, eu_business = EU member other than SE that
  matches the VAT prefix, non_eu_business = outside the EU), and the
  reverse-charge country gate.
- Writers: customer form and supplier form get a country select; internal
  REST, v1 REST, bulk-create, MCP create/update, CSV/Excel import and the
  provider migration mapper normalise to a code and refuse unknown text;
  the consistency rule is a form error and an API 400
  (CUSTOMER_COUNTRY_MISMATCH on update). An omitted country is SE for
  Swedish types, derived from the VAT prefix for eu_business, required
  for non_eu_business.
- vat-rules.ts: getVatRules and friends take the country as a third
  argument and grant reverse charge only for an EU country other than SE;
  every invoice/sales-order/MCP call site passes customer.country.
- periodisk sammanstallning reads legacy names through the same helper.
- Migration 20260903170000: normalize_country_code() SQL twin, country_raw
  rollback column on both tables, backfill of every non-code row; unknown
  text is left as-is. pg-real test for the function.

Closes #2025, closes #2028

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

* fix(customers): keep reverse charge for defaulted-SE EU rows, gate the country rule on the fields it reads, fix build

Skeptic and CI findings on #2241, one pass:

- Migration step 4: eu_business rows whose country was null or only the old
  writer default (SE) while the VAT number names another EU member take the
  country from the prefix. The pre-2026-09 rules granted reverse charge on
  type + VIES validation alone, so these rows invoiced at 0% and would have
  flipped to 25% on the next invoice. country_raw = '' marks a null origin;
  rollback uses nullif(country_raw, '').
- countryPermitsReverseCharge refuses SE only: a VIES-validated number
  outweighs a non-EU address (Swiss company registered in DE, Monaco with a
  FR number, Northern Ireland XI).
- checkCountryConsistency: an eu_business outside the EU VAT area is
  accepted when the VAT prefix is an EU-trade registration (incl. XI);
  Monaco maps to the FR prefix.
- Internal PATCH, MCP update and the commit executor judge the country rule
  only when customer_type, country or vat_number is part of the update, so
  a contradictory legacy row can still change its email (v1 already did).
- Webshop-order customers get the order's billing country; spreadsheet
  import derives a missing country from the type and flags contradictions
  (parser row error + execute schema refine).
- Build: v1 [id] route typed the existing row through a narrowed alias
  (never) and passed messageSv/messageEn the v1 error context lacks; the
  self-billed customer projection lacked country.
- Checks: regenerated skills/accounted-api (customer example country SE).
- New parity test holds the migration's SQL name table to the TS table.
- DECISIONS.md: correct migration version and the revised rule.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:09:46 +02:00
cb39cded81 fix(payroll): declare AGI for the payout month, not the run's period month (#2191) (#2228)
Arbetsgivardeklarationen is filed for the calendar month the pay went
out (kontantprincipen), so a run for August paid on 25 September belongs
to redovisningsperiod 202609. The generator, the submit route, the run
page and the run header all took run.period_year/period_month instead,
and three PATCH paths refused any payment date outside that month, which
made lön i efterskott impossible to set up at all.

- lib/salary/agi/reporting-period.ts: one dependency-free helper
  (agiReportingPeriod) derives the period from payment_date, falling
  back to the run period only when the date is missing.
- generate-declaration.ts: XML Redovisningsperiod, the agi_declarations
  lookup/insert and the sanity warnings key on the payout month. New
  AGI_PERIOD_CONFLICT (409) refuses to overwrite another live run's
  declaration for the same payout month; corrections still replace.
- submit route, run page (AGI panel, submission hook, tax-payment fetch,
  XML filename) and RunHeader use the helper; the header says "AGI
  redovisas för 2026-09 (utbetalningsmånaden)" whenever the two differ.
- The in-period payment-date guard is lifted in the dashboard PATCH,
  lib/salary/update-run.ts (MCP staged tool + pending-ops executor) and
  the v1 PATCH, plus the RunHeader min/max; its only stated reason was
  the period-keyed AGI. Generated API skill reference updated.

Existing agi_declarations rows keep their stored period: a declaration
already filed under the earned month is a correction with Skatteverket,
not a re-key. Rule verified against Skatteverket's guidance on
redovisningsperiod (kontantprincipen).

Closes #2191


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:19:19 +02:00