Commit Graph

1000 Commits

Author SHA1 Message Date
Jakob Wennberg 8e1f9d5201 fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach (#2291)
* fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach

The migration maps sales invoices from the provider's list payload and
hydrates the detail form (rows, net, VAT) inside a fixed 90 s budget, open
invoices first. Fortnox, Briox and Björn Lundén ship no rows in a list
response, so every invoice the budget did not reach was imported as a header
with a total and no invoice_items, and nothing ever came back for it: the
wizard never showed the hydration report, so the user found out on the
invoice page. Measured on prod today: Profilio 384 of 384 (migrated before
hydration existed), Loftux 311 of 672, Damac 182 of 542, Clearstoq 1 125 of
1 125.

- lib/providers: hydrateSalesInvoices() hydrates a caller-chosen subset of
  an already-listed register, so a follow-up can spend its budget on the
  invoices still incomplete on our side instead of re-walking the register
  open-first and never reaching the rest.
- arcim-migration: completeMigratedInvoiceLines() starts from OUR row-less
  non-draft invoices, joins them to the provider register on number + date
  (unique on both sides), hydrates only that subset and writes each
  invoice's rows once the detail total matches the stored total to the öre.
  The header VAT split is rewritten only when the stored one holds no
  evidence (null rate, or a non-zero rate label beside 0 kr VAT and
  subtotal = total). Never the total, status, payments or a journal entry.
- Hourly cron (/api/extensions/arcim-migration/complete-invoice-lines/cron,
  vercel.json + Docker crontabs) drives the pass over consents accepted in
  the last 60 days, newest first, with a per-company share of the run.
- The wizard's result screen now shows "x av y fakturor hämtade med rader"
  and that the rest are fetched in the background within the hour.

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

* fix(migration): write the header VAT fill as a literal, raise the schema-guard ceiling for the row inserts

The phantom-column scanner resolves only object-literal payloads. The header
update is now a literal (so its six columns are checked); the two
invoice_items inserts are runtime row arrays from mapSalesInvoiceLine, the
same shape the orchestrator already inserts, so the ceiling moves 399 to
401 with the reason recorded beside the earlier ones.

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

* fix(migration): gate the completion cron on token freshness, not consent age, and visit every usable consent

Two review findings held. Prod holds 57 accepted consents from the last 60
days, so a fixed page of the newest 25 would leave older companies with
row-less invoices waiting behind companies that are already done: the cap
is gone (a company with nothing left costs one query and no provider call).
And the consent's created_at said nothing about whether its credentials
still work: Fortnox refresh tokens live 45 days and rotate on every
refresh, so eligibility is now read off the token row (access token expired
within the last 45 days, or no expiry at all), which also stops a dead
consent from being retried every hour.

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

---------

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 10:20:10 +02:00
Jakob Wennberg 0bd3c27fba fix(assistant): the salary fact follows the ledger, never the column default (#2290)
* fix(assistant): the salary fact follows the ledger, never the column default

The in-app assistant told a payroll-running aktiebolag in every answer that
it "betalar inte löner" (support case 2026-09-04). company_settings.
pays_salaries is NOT NULL DEFAULT false and only the Skatt settings form
writes it, so for every company that never opened that form the flag reads
false whatever the ledger says, and lib/agent/ask/snapshot.ts asserted that
default as a fact.

- Trigger salary_runs_booked_marks_employer (20260904191000): a booked
  salary run sets pays_salaries = true and fills a never-attested
  employer_registered, at the one place every writer (dashboard, MCP, v1,
  seeders) passes through. Backfill for the 12 companies on prod already
  booking payroll with the flag at its default (8 of them also lacked the
  employer flag, and with it their AGI deadline reminders). An explicit
  employer_registered = false stays the user's answer.
- The assistant snapshot applies the composer's employee-facts doctrine:
  positive evidence (active employees, the flag, an attested employer
  registration) yields the fact, only an attested negative yields the
  negative, the default yields nothing. It also names Inställningar >
  Skatt / > Bokföring so the model can point at the page.
- The composer's KÄNDA FAKTA no longer prints "Betalar ut lön: nej" from
  the same default.

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

* fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion

A NULL source_company_id in the subquery would make NOT IN never true and
silently skip the whole backfill.

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

---------

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 09:49:21 +02:00
Jakob Wennberg e684606b23 fix(supplier-invoices): a credit note is never a payable, so it never waits for attest (#2289)
* fix(supplier-invoices): a credit note is never a payable, so it never waits for attest

A supplier credit note created with Kreditera was inserted at 'registered',
the attest entry state, while the detail page (rightly) offered no attest
for it. The worklist counted every 'registered' row, so Att göra showed
"1 leverantörsfaktura att attestera" that nobody could clear (support case
2026-09-04; 14 such rows on prod plus one MCP-approved credit note).

- One row builder (lib/supplier-invoices/credit-note.ts) for the dashboard
  route, the MCP executor and the v1 API: the credit note rests at
  'credited' from birth, the status the provider importers already use.
- CHECK supplier_invoices_credit_note_not_payable keeps every writer out of
  the payable states; migration backfills the stuck rows (one immutable
  reset-source row skipped, hence NOT VALID).
- The worklist attest count excludes credit notes explicitly.
- GET /api/supplier-invoices/[id] hydrates credited_original with a second
  scoped query: PostgREST cannot pick a direction for a self-referencing
  embed hint and returned the one-to-many side (an empty array), which the
  page rendered as "Krediterar: Ankomst #" with no number.

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

* test(supplier-invoices): type the GET route response in the credited_original tests

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

* fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion

A NULL source_company_id in the subquery would make NOT IN never true and
silently skip the whole backfill.

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

* test(schema): raise the unresolved-payload ceiling by 3 for the credit-note row builder

The three credit-note creation paths now insert the row from one builder,
so the scanner sees three dynamic payloads instead of three literals. The
columns are the literal in lib/supplier-invoices/credit-note.ts, pinned by
its test; the resting status is additionally held by the DB CHECK.

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

* test(pg): credit-note fixtures in the overdue-cron tests rest at credited

The two fixtures that seeded a credit note on 'registered'/'overdue' now
violate supplier_invoices_credit_note_not_payable. The cron test seeds the
credit note the way the routes create it since 20260904190000; the 20260607
backfill case asserts the scenario it repaired is now unreachable.

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

* test(pg): ledger-usage-stats seeds its credit note at credited

The fixture defaulted every row to 'registered', which the CHECK
supplier_invoices_credit_note_not_payable now refuses for a credit note.

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

---------

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 09:43:00 +02:00
Jakob Wennberg a870c7f03e fix(import): attach underlag by the basename of a folder-picked upload (#2288)
A folder-picked Fortnox export failed 50 of 50 attaches with
UNDERLAG_REF_MISMATCH although the preview had matched every file. The
preview is built from File.name, a bare filename by spec, while the attach
route read the multipart filename, which Chrome fills with the folder-relative
path for folder selections (2026/06/Leverantorsfakturor/A166_x.pdf). The
guard that requires a file to land where the preview said compared the
previewed basename with a path the parser cannot read, and refused.

The route now reduces the multipart filename to its basename once, at the
boundary, before the resolver check and before archiving, so the archived
file_name is the name the user reviewed rather than a path. The parser keeps
its no-directory-stripping rule: the manual-reference box shares it, and a
typed 2024/01/31 there is a date, not voucher 31. Both separators are
stripped; nothing else is normalized.


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

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 19:14:33 +02:00
Jakob Wennberg 9618bab273 fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.

1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
   seeds the next forward year and kept that name when the user re-dated the
   form. The name now follows the typed dates until the user edits the name
   (fiscalYearName exported from suggest-fiscal-period).

2. Nollställ of the backfilled year was refused with next_year_dependency
   because 2024 carried an opening-balance verifikat. Any IB in the next year
   counted as reliance, so a backfilled year could never be reset, while a
   next year WITHOUT an IB (whose balansrapport really rolls from this year)
   was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
   the block fires only when the next year is locked, closed or has its own
   closing entry; a bokslut-generated IB is still refused via this year's
   closing_entry_id (year_end_state). The snapshot returns next_period
   {id, name, has_opening_balances} and the dialog states that the following
   year's IB stays as it is.

pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).

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 18:49:23 +02:00
Jakob Wennberg db289e3bdc fix(payments): lock supplier payment batch inserts to the RPC and log the raw error behind create_failed (#2282)
* fix(payments): lock supplier payment batch inserts to the RPC and log the raw error behind create_failed

Two residuals from PR #1989 (atomic create_supplier_payment_batch RPC).

Root cause 1: the original table migration (20260810160748) left member
INSERT policies on supplier_payment_batches and supplier_payment_batch_items.
The RPC is SECURITY DEFINER and never consulted them, so their only effect
was to let any company member insert straight through PostgREST (browser
devtools, a raw JWT call) and skip the RPC's invoice locking, in-transaction
active-batch recheck and header/items totals consistency. The single write
path existed in code only, not in the database.

Fix 1: new migration 20260904121000 drops
  "insert own-company supplier_payment_batches" and
  "insert own-company supplier_payment_batch_items".
SELECT policies on both tables and the UPDATE policy on batches (the cancel
route) are untouched. No application code inserts into either table.

Root cause 2: createSupplierPaymentBatch discarded the RPC error object and
returned a bare create_failed, so the tenant guard (42501), a constraint
violation inside the SECURITY DEFINER body and a PostgREST schema-cache miss
after a deploy (PGRST202) were indistinguishable from each other and from an
empty payload or an unmapped refusal code.

Fix 2: log the raw error (code, message, details, hint) plus companyId,
batchId and item count through lib/logger before each of the three
create_failed returns. The client-facing result is unchanged; debtor_snapshot
and the item rows (IBAN, payee data) are never logged.

Tests: pg-real asserts the exact remaining policy set, that a member's and the
owner's direct INSERT into either table is refused by RLS (42501), and that the
same member still creates through the RPC and cancels through UPDATE. Unit
tests assert the logger receives the raw error fields and that create_failed
is still returned.

Fixes #2060

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

* docs(decisions): carry the ten-issue batch decision lines in one PR

Append the decision lines for PRs #2272 through #2282 here so the other
nine PRs in the batch do not touch DECISIONS.md and stay mergeable in
any order (the union merge driver is ignored by GitHub).

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

* fix(payments): redact and bound raw RPC error text before logging

Addresses the Superagent P2 on PR #2282 (lib/payments/batch-service.ts):
message, details and hint from Postgres/PostgREST were logged verbatim, and
Postgres quotes the entire failing row in details on CHECK and NOT NULL
violations ("Failing row contains (..., SE45..., Anna Andersson, ...)"), so
payee and account data could reach the log line. Excluding debtor_snapshot
and the item rows did not cover the error text itself.

Fix: a call-site helper, boundedRedactedText, runs each of the three text
fields through lib/observability/redact.ts redactString (SE IBANs,
personnummer, emails, API keys), drops any "Failing row contains (...)"
payload whole (no pattern catches a payee name), and bounds the result to
500 chars, redaction before bounding so a cut IBAN cannot leave a digit
fragment behind. The SQLSTATE code stays verbatim; the client-facing
create_failed result is unchanged.

Test: rejected RPC error carrying an IBAN in message, the full failing row
(IBAN, payee name, account) in details and an oversized hint with the IBAN
straddling the bound; asserts the serialized log context contains none of
them, the row payload is replaced, and the hint is <= 500 chars ending in
[TRUNCATED]. DECISIONS.md line for #2060 updated accordingly.

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

* fix(payments): drop the dotAll regex flag, tsconfig targets ES2017

The failing-row pattern used the `s` flag, which TypeScript rejects
below es2018 (TS1501) and broke Build (zero extensions). `[\s\S]*`
matches across newlines on every target.

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

* fix(payments): log code and message only for a failed batch RPC

Reworks the logging half of #2060 from first principles. The diagnostic
value of a failed create_supplier_payment_batch call lies in the SQLSTATE
code and the message: the RPC's own RAISE text, "violates check constraint
<name>", "duplicate key value violates unique constraint <name>". details
is exactly where Postgres puts row data ("Failing row contains (...)",
"Key (...)=(...)") and hint adds nothing operational, so neither is logged
at all.

That removes the payee/account exposure Superagent flagged on #2282 without
the bespoke redact-and-bound helper, its regex and the TS-target workaround
it needed: boundedRedactedText, FAILING_ROW_PATTERN, RPC_ERROR_TEXT_MAX and
TRUNCATED are deleted, and the redact import goes with them. The logger's
own redaction stays as the safety net for message. Client-facing result
unchanged (create_failed).

Test: an RPC error carrying an IBAN and a payee name in details and hint;
the serialized log context contains neither field in any shape, and
rpcError is exactly { code, message }. Exact-match and PGRST202 tests
updated to the two-field shape. DECISIONS.md line for #2060 rewritten.

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

* docs(decisions): record the first-principles rework of the ten-issue batch

Replace the decision lines for #2263, #2250, #2256 and #2211 with the
reworked shapes, add the shared customer-share definition for #2248,
and note the CLAUDE.md principle (#2283) that drove the rework.

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

* docs(decisions): note the fiscal-year selection cap on #2280

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:19:18 +02:00
Jakob Wennberg 743e3ae7cc fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277)
* fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments

The dashboard match-invoice route, its v1 twin and the pending-operation
match_transaction_invoice executor wrote invoice_payments.amount as the
cash received in invoice currency. When a whole-krona bank line settles an
öre-carrying remaining (the customer pays the rounded "Att betala"),
planInvoicePayment advances paid_amount by the remaining only and books
the öre on 3740, so the row exceeded the receivable by the absorbed öre:
remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60
paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with
negative scaled moms, the historical AR ledger showed -0.40 outstanding
on a paid invoice, and a storno of the payment voucher restored
paid_amount 0.40 off (issue #2250).

PR #2236 defined the amount for the manual, MCP and Stripe paths as the
amount APPLIED to the invoice (new paid_amount minus the prior one). The
three bank-match paths now share that definition through one helper,
appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which
recordInvoicePaymentRow() uses as well. Every other field of the row
(payment date, currency, exchange rate, journal entry, bank transaction,
notes) is unchanged. Without a residual the applied amount equals the
cash received, so ordinary matches post identical rows; cross-currency
rows are now öre-rounded like paid_amount instead of the 4-decimal spot
conversion, so row and paid_amount agree.

Existing rows carrying the overshoot are not repaired here; that is a
separate call.

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

* refactor(invoices): one writer for invoice_payments rows

Rework of the #2250 fix from first principles. The bank-match paths did
not just get the amount wrong; the class of bug is that invoice_payments
rows were hand-built at five product sites (dashboard bank match, its v1
twin, the pending-operation match, the link-to-existing-voucher flow, and
the #2236 paths through the helper), each computing its own fields with
no single definition of what the row means.

recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the
one writer. Its options grew by what the bank paths set, all optional with
today's defaults so the #2236 callers are unchanged: transactionId
(default null), exchangeRate (the rate actually used; omitted =
invoice.exchange_rate, explicit null stored as null) and notes (default
null). The failure result carries the Postgres SQLSTATE so the routes keep
mapping a unique violation (23505) exactly as before. The applied-amount
formula is an internal detail of that file again.

Routed through the writer: app/api/transactions/[id]/match-invoice, the
v1 match-invoice twin, commitMatchTransactionInvoice in
lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts
(strict plan, same currency only: its amount is unchanged, it now shares
the row semantics). The pending-operation path used to drop the insert
error on the floor; it stays non-fatal but is logged with ids.

Guard: scripts/checks/no-new-antipatterns.mjs gains
direct-invoice-payment-insert, a file-set rule with no baseline (0 today):
.from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or
extensions/ outside lib/invoices/invoice-payment-row.ts fails
npm run check:guards. Operator scripts under scripts/ are out of its scope
on purpose.

Tests: the writer's unit tests cover the new options, the explicit-null
rate, the SQLSTATE passthrough and the öre-rounded prior-paid
subtraction; the per-path 3740 tests from the first commit stand; mock
insert slots now return the row id the writer selects back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:13:29 +02:00
Jakob Wennberg 2d927349d3 fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint (#2279)
* fix(payroll): enforce the jamkning both-dates invariant with a database trigger

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code. Two writes could still store the inert shape the
engine never applies: (1) concurrent PATCHes, where both handlers
validate a fetched snapshot and then issue an unconditional partial
update, so a { jamkning_valid_to: null } that committed last left a
percentage without an end date; (2) direct SQL and service-role writes,
which bypass the validator entirely.

Fix: migration 20260904120000 adds trg_enforce_employee_jamkning_dates,
BEFORE INSERT OR UPDATE OF jamkning_percentage, jamkning_valid_from,
jamkning_valid_to ON employees. It mirrors validateJamkning: a non-null
percentage needs both dates, and valid_to may not precede valid_from.
On INSERT it always checks; on UPDATE it checks only when one of the
three columns actually changes (IS DISTINCT FROM on OLD vs NEW), so a
legacy incomplete row stored before #2240 stays editable in unrelated
ways, including by a route that writes the whole row back. The error is
SQLSTATE 23514 with the stable prefix "JAMKNING_INCOMPLETE: " followed by
the same Swedish sentence the validator produces. The function is
SECURITY INVOKER with search_path pinned. No backfill: existing
incomplete rows are listed by scripts/list-incomplete-jamkning.ts and
decided per company.

App side, jamkningIssueFromDbError in lib/salary/jamkning-rules.ts
recognises the trigger rejection, and the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor) answer it with the same
400 / VALIDATION_ERROR and sentence as the merged-state check, instead
of a generic 500 / INTERNAL_ERROR.

Tests: tests/pg/employees-jamkning-trigger.pg.test.ts (25 cases against
real Postgres, including the interleaved two-transaction race), unit
tests for the helper, and one race test per update path.

Fixes #2256

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

* fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code, across many writers. Two concurrent PATCHes that each
validated a fetched snapshot and then wrote unconditionally could leave
a percentage without an end date (the engine never applies such a
beslut, so the payslip and AGI silently carry the table tax), and
direct SQL or service-role writes never saw the validator at all.

Fix, from first principles: the invariant is a row-level fact, so it is
declared as a row-level CHECK constraint, employees_jamkning_dates_check
(migration 20260904120000), added NOT VALID so the migration cannot
fail on production because of rows stored incomplete before #2240.
From now on every INSERT and every UPDATE of any row is checked. This
replaces the trigger the issue proposed: no plpgsql function, no
per-column change detection, no custom message convention, and the
rule is visible in the schema.

One behavioural difference from the proposal: a legacy incomplete row
is refused on its next edit, related or not, until the beslut is
completed (both dates) or cleared (percentage null). The application
maps that rejection (SQLSTATE 23514 naming the constraint) to the
validator's own Swedish sentence in the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor), so the user is told
exactly what to complete; a rejection the merged row cannot explain
(a concurrent change) gets an umbrella sentence. No backfill: those
rows are listed by scripts/list-incomplete-jamkning.ts and decided per
company.

Tests: tests/pg/employees-jamkning-check.pg.test.ts against real
Postgres (constraint shape, INSERT and UPDATE rejections and
acceptances, the interleaved two-transaction race, the legacy
consequence), unit tests for the mapping, and race plus legacy tests
per update path. The PostgREST error shape was verified against a real
PostgREST: the constraint name is in `message`, `details` carries the
failing row and is never forwarded.

Fixes #2256

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:01:34 +02:00
Jakob Wennberg 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
Jakob Wennberg be0478c219 fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513 (#2278)
* fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513

Root cause: collectKontantmetodCutoff compared invoice_payments against
the gross invoice total and never read deduction_total. Under
fakturamodellen the customer owes total minus the skattereduktion; the
deduction is a fordran on Skatteverket carried on 1513 by the payment
voucher (Dr 1930 customer share / Dr 1513 deduction / Cr 30xx / Cr 26xx
in full), and every settlement path records the customer share as the
payment row amount. A fully paid ROT/RUT invoice therefore showed exactly
deduction_total as outstanding, and the year-end cut-off booked a phantom
Dr 1510 / Cr 30xx / Cr 2618 on top of a sale whose revenue and moms were
already fully recognised: revenue overstated and vilande moms invented.

Fix: the customer's outstanding is total - deduction_total - paid (the
deduction follows the sign of the total, since credit notes store it as
a positive magnitude), floored at zero on the invoice's own side for
over-collection noise, mirroring the invoices_remaining_amount_guard
formula. The moms carried into the cut-off is scaled by the customer
share, not the gross total, so an unpaid ROT/RUT invoice reports its
whole moms in the final period and a part-paid one the matching
fraction. Invoices without a deduction take the unchanged gross path.

The readiness gate, the pending-operation executor and the MCP tool all
consume this collector, so they inherit the fix. The Skatteverket share
itself (an unpaid ROT/RUT invoice's 1513 fordran at year end) is not
part of the cut-off and stays a separate change, as is the 1513 point
already deferred on the currency revaluation.

Fixes #2248

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

* refactor(invoices): one definition of the customer share of an invoice

The customer share of an invoice (total minus the ROT/RUT deduction,
and what remains of it after payments) was re-derived by hand at three
TypeScript sites plus the SQL guard invoices_remaining_amount_guard, and
the kontantmetod cut-off's copy had drifted to the gross total (#2248).
Fixing the cut-off's arithmetic alone would leave the next copy free to
drift the same way.

lib/invoices/customer-share.ts now holds the definition:
invoiceCustomerShare(invoice) returns total minus sign(total) times
deduction_total (the deduction is stored as a positive magnitude, also
on credit notes), and invoiceCustomerOutstanding(invoice, paid) returns
the signed residual after payments. The module comment names the SQL
twin (migration 20260817191708) so the two stay in lockstep; the SQL is
untouched.

Call sites moved onto the helper with byte-identical behaviour:
kontantmetod-cutoff.ts (keeps its as-of payment sum, the sign-aware zero
floor for ROT/RUT rows and the moms scaling), rot-rut-file.ts (the
customer-share-paid test) and payment-sync.ts (the storno path, which
still floors with Math.max(0, ...) because it persists the column).
Plain invoices get their total back exactly as stored, so their paths
do not change by a bit. New unit tests cover plain, ROT/RUT, credit-note
sign, null deduction and the lockstep with the guard's formula.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:01:06 +02:00
Joakim Hansson 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
Joakim Hansson 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
Mattsson 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
Jakob Wennberg 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
Jakob Wennberg 227a6317f1 fix(import): label the SIE preview IB total as "summa debet", not "IB Summa" (#2142)
The fourth stat card in the SIE preview showed the debit-side total of the
opening-balance voucher under the label "IB Summa". A user read it as the
net ingående balans and could not reconcile it against any single figure.

- Relabel the card "IB, summa debet" and add a one-line helper saying it is
  the sum of all debit balances in IB, not a single account balance. The
  number equals "Total debet" in the Balansräkning (IB) card right below.
- Review step: "Skapar IB-verifikation, summa debet X" instead of
  "Skapar verifikation för IB på X".
- Comment the field in generateImportPreview so the meaning is explicit.

No data or logic change: openingBalanceTotal keeps its semantics.


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

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 09:42:41 +02:00
Mattsson 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
Jakob Wennberg 01d903d3a3 fix(mcp): link_document_to_voucher marks the inbox item handled; document lists stop misleading agents (#2170)
Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit
the same hole: link_document_to_voucher attaches the document to the
verifikat but never stamps the inbox item it came from, and both inbox read
surfaces derive "handled" from the inbox row's own link columns, never from
document_attachments.journal_entry_id. So an attached document stayed
"unprocessed" forever: agents re-saw it as missing underlag (one reporter
paged 750 rows to find the ~15 real ones), and one user read the five
leftover rows as duplicates and was about to delete the only copies of
underlag sitting on posted verifikat.

- commitLinkDocumentToVoucher and the bulk twin now stamp
  invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on
  both link columns, 23505 tolerated (samlingsverifikat). Same shape as the
  create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping
  never rolls back a committed link.
- gnubok_list_unmatched_documents returns file_name (same embed
  list_inbox_items uses) and the extraction's page coverage, so an agent can
  tell "no total on this document" from "we read 3 of 38 pages" and does not
  have to fetch each document to learn what it is (seq 265062, 288574).
- gnubok_list_transactions_without_documents no longer echoes the column
  default "uncategorized" on rows that are booked by construction:
  list_uncategorized_transactions uses the same word for "no journal entry
  yet", and an agent read the label and tried to re-book an already-booked
  share-capital deposit (seq 288574).

No tools/list payload change: the unmatched-documents item schema is
untyped and the category field already allowed null.


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

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 08:57:53 +02:00
Mattsson 9ab823a43c fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies (#2260)
* fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies

#2233 merged, but its first migration (20260903150000) failed on prod at
the backfill's INSERT into invoice_payee_defaults:

  ERROR: Archived migration reset source records are immutable (P0001)

The insert fires the SECURITY DEFINER mirror into company_settings, and
one of the companies with a legacy payment map is a migration-reset
source, whose rows are immutable by trigger. The migration rolled back as
a whole, prod has neither table nor column, and every migration merged
after it is queued behind the failure.

Same fix as #2249 used for the country backfill: both entry branches of
the backfill now skip companies present in company_migration_resets, and
both files are re-issued under fresh versions (20260904010000 and
20260904011000) so Supabase applies them in order after everything that
landed today. The failed versions never applied on prod, so no orphan;
staging applied them by hand and its schema_migrations rows must be
renamed to match (see DECISIONS.md).

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

* fix(migrations): make the re-issued payee migrations rerunnable

pg-upgrade builds from main, where the first issue (20260903150000)
already ran, then applies the re-issued file on top: the composite UNIQUE
constraint already existed. Every statement in both files is now guarded
(constraint DO blocks, CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY /
DROP TRIGGER IF EXISTS before each CREATE), so staging and the preview
branches that applied the first issue take the re-issue cleanly too, and
prod, which never applied it, is unaffected.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 06:45:56 +02:00
Jakob Wennberg 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
Mattsson d670fe6663 feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account

Enable Banking has no top-level `bban` key on AccountIdentification: a
Swedish BBAN (clearing + account number) arrives as `other.identification`
with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed
`bban?: string` and read `.bban`, so the value was always undefined: no
connected account ever carried its clearing + account number, and domestic
counterparty accounts on transactions were dropped.

Type the identifiers per the OpenAPI spec, add extractBban() and
pickAccountIdentifier(), read counterparty identifiers through the scheme
list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on
StoredAccount from the OAuth callback. The external_id dedup scope stays
IBAN-then-uid and is untouched.

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

* feat(invoices): named payee accounts on cash_accounts with a default per currency

A company had exactly one set of payment instructions per invoice currency
(company_settings.invoice_payment_accounts), picked by currency alone. A
second SEK bank account, or a second bankgiro number, had nowhere to live.

cash_accounts is already the per-company bank-account entity. Migration
20260903150000 adds the payee fields (bankgiro, plusgiro, clearing +
account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a
small invoice_payee_defaults table (one default account per currency; one
account may be the default for several currencies, a SEK account with an
IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites
the legacy map and the SEK bank columns from the default accounts. Every
existing reader (PDF, email, reminders, v1, MCP) keeps working; the three
writers that only touched legacy columns (PUT /api/settings, v1 settings,
MCP update_company_settings) now write through to the default account, so
what an agent sets is what the PDF prints. Peppol PaymentMeans is built
from the resolver instead of the raw legacy column. bg_pg is dropped
(never read or written; NULL on every prod and staging row).

Backfill lands only on existing cash accounts (primary, IBAN match, or the
only enabled account in the currency). Entries with no target stay in the
map as the resolver fallback and get an attach action in settings.

New: POST /api/cash-accounts (manual bank account on the next free 19xx),
PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT
/api/cash-accounts/payee-defaults. Settings page rewritten as an account
list with per-currency defaults. Behandlingshistorik and the full archive
cover the new table and columns.

Verified on staging: migration applied (11 defaults landed), mirror
trigger observed rewriting company_settings from a payee edit.

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

* feat(invoices): choose which bank account an invoice is paid to, frozen at issue

Migration 20260903160000 adds invoices.payment_cash_account_id (FK to
cash_accounts, SET NULL) and invoices.payment_details, the payee fields
frozen when the account is chosen and refreshed at issue.

Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount /
assertInvoicePaymentAccountForRender take an optional override, and
hasRequiredInvoicePaymentAccount reads it from the invoice row, so every
surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol,
recurring, staged MCP send) prints the frozen payee when one exists and the
company default per currency otherwise. Invoices that never chose an
account behave exactly as before.

Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send,
recurring, MCP send and mark-sent) refresh the snapshot from the account as
it is at issue; a chosen account that is disabled, un-flagged or unusable
for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID.

Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice
accept payment_cash_account_id and validate it against the company's payee
accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the
original's payee; copies carry the choice; preview-pdf renders the chosen
account. The editor shows "Betalas till" under the currency when the
company has two or more usable payee accounts for that currency.

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

* feat(invoices): book manual payments on the invoice's chosen bank account

Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the
booking dialog's proposed lines debited 1930 regardless of which bank
account the invoice asked to be paid to. They now resolve the chosen
payee account's ledger account (resolveInvoiceSettlementAccount) and fall
back to 1930 only when no account was chosen or the row is gone.

Bank-transaction matching keeps debiting the account the money landed on
and does not filter by the chosen account; between equal-confidence
candidates it prefers the invoice that asked to be paid to the landing
account. Scores are untouched, so nothing new auto-matches.

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

* chore(invoices): keep the payload-size and phantom-column ceilings after the payee work

Shorten the new gnubok_create_invoice argument description (tools/list
payload was 29 bytes over the 60 kB budget), inline the cash-account payee
UPDATE/INSERT payloads and the settings select strings as literals so the
phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE
instead of a hand-rolled copy.

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

* fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK)

Review findings from CodeRabbit, Superagent, the Swedish accounting review
and three skeptic passes, resolved in one batch:

Schema (both migrations are unshipped and edited in place):
- cash_accounts.payee_iban: the printed IBAN is its own column. iban stays
  the bank identity written by every sync and used to re-pair on reconnect,
  so a sync can no longer rewrite an invoice instruction or resurrect a
  cleared IBAN. The backfill copies each currency entry verbatim onto the
  target account (IBAN match first, then primary), so every invoice keeps
  printing exactly what it printed before; the bank IBAN is never pushed
  onto invoices that did not carry one.
- Payee columns are owner/admin-only at the database (BEFORE trigger,
  service role exempt): cash_accounts is member-writable for bank sync, and
  the SECURITY DEFINER mirror would otherwise have let a member rewrite
  where customers pay.
- Revoking an account as payee or disabling it drops its defaults; deleting
  a default drops that currency from the map and clears the legacy SEK
  columns (an admin saying "nothing to print" must not keep printing a
  closed account). The mirror leaves the legacy SEK columns alone when the
  map has no SEK entry, so legacy-only companies are never wiped by a
  mirror run for another currency.
- Audit and mirror triggers fire on the same column set; anon and
  authenticated can no longer execute the trigger-only definer functions.
- invoices.payment_cash_account_id is a composite same-company FK with
  SET NULL scoped to the account column.

Code:
- Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now
  also requires enabled, payee-flagged and usable for the currency),
  resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which
  also refuses disabled rows and logs every fallback to 1930).
- createManualBankAccount excludes every ledger slot any row already holds
  (findFreeLedgerAccount treats a manual holder as free; this path inserts).
- The legacy settings writers (PUT /api/settings, v1, MCP) write through to
  the account BEFORE updating company_settings and fail the request on
  error; the account is written before it is adopted as default so the
  mirror never sees an empty payee.
- snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write
  blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid
  projections carry the payee columns; v1 create validates the payee
  before the dry-run return and echoes it in the preview.
- pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and
  non-account schemes (card PANs) are never persisted.
- Editor shows the payee select for a single usable account with no
  default; the booking dialog waits for cash accounts before proposing
  lines; a failed default write no longer hides a created account.
- Behandlingshistorik names the account on created/deleted defaults.
- Regenerated skills/accounted-api; MCP argument description trimmed under
  the tools/list payload ceiling.

Declined: clearing legacy columns via a forward migration (the mirror now
does it on delete); Swedish review's "show the debit account in the
mark-paid UI" (the booking dialog already proposes and lets the user edit
the debit line); manual ledger collision (UNIQUE exists, and the create
path now rejects it with a clear error); Peppol aligning to the PDF value
for companies whose legacy column had drifted from the map (the PDF is the
customer-facing document; both now agree).

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

* fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger

trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves
record fields per expression, so the combined condition failed with
"record new has no field invoice_payee" whenever a default row changed,
which took down every pg-real case on the payee tables. The revoke/disable
check now sits inside its own TG_TABLE_NAME branch. The MCP settings
executor test mocks the payee write-through like the settings route test
already does.

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

* fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload

Cycle 3 of /resolve-pr on #2233.

Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's
invoice_payee_defaults rows whenever cash_accounts.enabled flipped to
false, and enabled is member-writable (the bank picker's "Synkas ej"), so
a member could undo an admin's payee decision. The trigger now drops
defaults only on the admin-only invoice_payee true -> false revoke; the
mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out
of the pick lists and the send gate already refuses an invoice that chose
one. Applied to staging as the same function + trigger definition and
probed inside a rolled-back block: disable keeps the default and the
mirrored bankgiro, revoke clears both.

pg-real: the admin-guard test ran three expectations inside one
withUserContext transaction; the first raise aborted it and the next
statement failed with "current transaction is aborted". One transaction
per expectation now, and the member case also flips enabled to prove the
column stays member-level.

Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa
and the 1911-1919 tills. A customer pays to a giro or bank account, so
isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH
route now require BAS 1920-1999; tests cover 1910 and 1919.

Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014
tokens after main merged #2166 and #2163 alongside this branch. The
ceiling is not bumped and no read on this surface is a demotion
candidate, so gnubok_create_invoice drops payment_cash_account_id;
agent-created invoices print the per-currency default and v1 REST plus
the editor keep the field. Recorded in DECISIONS.md.

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

* chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration

origin/main merged 20260903160000_kpi_monthly_include_reversed_originals
while this branch held the same version; identical versions abort the
Supabase apply. Staging's schema_migrations row was moved to the new
version with the file.

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

* fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet

Cycle 4 of /resolve-pr on #2233, on Emil's go.

Swedish review: the 1920-1999 payee rule lived only in the routes. The
cash_accounts_payee_admin_only trigger now also refuses invoice_payee on
any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes
it, and the backfill only targets giro/bank rows, so a company whose
single enabled cash_accounts row is a Stripe clearing account keeps its
legacy bankgiro in company_settings instead of landing it on 1686. pg
test covers insert and update on 1686 and 1910; the function was applied
to staging and probed.

Typecheck ratchet: main is red from two merges that landed with failing
Checks, and every branch that syncs it inherits the errors.
  - #2242 added POST(req) calls to the fiscal-periods route test without
    the route params argument withRouteContext handlers take (25 errors
    in the file, baseline 23). All 25 calls now pass
    createMockRouteParams({}).
  - #2247 made SyncResult.requestedFromDate and historyNarrowed required;
    the 13 mockedSync results in the enable-banking accounts-route test
    lacked them. They now carry a fixed date and historyNarrowed: false.
Both files' tests pass unchanged in behaviour.

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

* chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion

origin/main merged 20260903183000_party_promotion while this branch held
the same version. Staging's schema_migrations row must follow (pending:
the Supabase MCP was disconnected at the time of this commit).

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:06:45 +02:00
Mattsson 88a5d78594 fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181) (#2244)
* fix(inbox): trace every received mail and file multi-recipient mail once per inbox (#2181)

A mail sent to both the +lev and +ver address of one inbox was read as
its first recipient only, and an attachment whose processing threw left
no row at all: the webhook answered 200, Resend never retried, and the
document was gone with nothing for the user to find. Prod showed both
shapes for the reporter (a +lev mail Resend accepted with zero inbox
rows, and the second PDF of the +ver mail missing).

- The webhook now reads every shared-domain recipient, groups them per
  inbox, files once per inbox with a company-scoped dedupe key, and
  resolves contradicting tags (+lev and +ver on one mail) to no hint so
  extraction classifies.
- The per-attachment catch writes an error row instead of only a
  console line.
- One InboundMailReceived behandlingshistorik event per mail and inbox
  records recipients, tags, hint, conflict and the outcome per
  attachment (filed, duplicate, rejected, failed). No sender or
  subject, matching the existing PII rule.
- GET /inbound-history?days=30 serves those events, company-scoped, and
  the inbox workspace shows them under Källor as "Inkomna mejl", each
  filed row a click away.
- The list says how many rows the type filter is hiding, with a click
  back to all types.
- Migration 20260903190000 registers the event type and replaces the
  (email, attachment) unique index with (company, email, attachment).

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

* fix(inbox): keep addresses and sender-typed tags out of the mail record, and let redelivery heal a transient failure

Skeptic pass on #2244, two refutations:

- The InboundMailReceived payload carried the recipient addresses and every
  plus-tag verbatim. An enskild firma's inbox local part is the owner's
  name, the tag is whatever the sender typed, and processing_history is
  append-only and outside the erasure path; a numeric tag also tripped the
  PII validator so the record was silently dropped. The event now carries
  inbox_id, the documented tags (+lev/+ver), an unknown-tag count and the
  outcome codes. The history route resolves inbox_id to the company's own
  address at read time. The DB strip trigger from 20260901110000 covers the
  new type (and is recreated, since staging skipped that file).
- The catch-path error row made a Resend redelivery report "duplicate", so
  a transient download or storage failure that used to self-heal on retry
  became permanent. The row is marked transient and a redelivery replaces
  it; rejections (bad type, too large) stay duplicates.

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

* fix(inbox): cap inbound fan-out, flag a truncated mail history, and name a replaced transient row

Review pass on #2244: Superagent (bound the number of inboxes one mail can
fan out to: five), CodeRabbit (the history route now returns has_more past
200 rows and the panel says so instead of "every mail"), and the Swedish
accounting review (a redelivery that replaces a transient error row names
the replaced row on the InboundMailReceived record, so the replacement
leaves a trace). The migration comment states why the index swap is not
CONCURRENTLY: Supabase branching applies migrations in a transaction.

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

* fix(inbox): resolve every addressed inbox and record the ones past the fan-out cap

CodeRabbit and the Swedish accounting review on #2244: slicing recipient
groups before the lookup let five unknown local parts starve a real inbox
and left companies past the cap with no trace. Every addressed inbox is
now resolved (one cheap lookup each), the first five are processed, and
the rest get their own InboundMailReceived record with outcome
fan_out_capped, shown in the panel as "not processed".

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

* chore(inbox): move the inbound-mail migration past the parties versions merged tonight

Main moved party_decision_undo to 20260904000100 and added
20260904000200 (#2257, #2258). A version below prod's head is skipped by
Supabase branching, so 20260903190000 becomes 20260904001000 unchanged.
Staging re-tracked under the new version.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:47:24 +02:00
Mattsson b07efcafd4 fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)
* fix(payroll): require jamkning valid_to on every write path (#2058)

A jamkningsbeslut saved through the v1 API or MCP with a percentage and a
start date but no end date was stored and returned 200, yet the engine
(isJamkningValid) never applies a beslut without both dates: the payslip
and the AGI carried the table tax while the caller believed the beslut
was live.

One shared validator (lib/salary/jamkning-rules.ts) now requires both
dates whenever a percentage is set and checks their ordering. Every write
path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST
and PATCH routes, the v1 PATCH route (its private copy is deleted), the
MCP create and update executors in employee-commands, and the MCP update
tool preflights the merged row at staging time so the agent sees the
error before approval. The update paths keep the existing touched gate, so
legacy rows stored without valid_to stay editable in unrelated ways.

The MCP tool descriptions state that both dates are required for the
beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing
rows (percentage set, valid_to null) per company, read-only; setting an
end date or clearing the beslut is decided per company since either
changes the next payslip.

Declined: defaulting valid_to to 31 December of the from-year. It matches
most beslut but silently changes withholding on rows that today do
nothing.

Closes #2058

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

* fix(payroll): keep the jamkning PR inside the type and tools/list budgets

CI on the first push failed on two ratchets this PR itself tripped:

- Typecheck ratchet: the three staging tests added here reused the
  untyped 'agent_chat' actor literal the file already carried, which
  raised that file's error count above its baseline. They now pass
  { type: 'user' }.
- tools/list payload budget: the first jamkning field descriptions on
  gnubok_create_employee and gnubok_update_employee pushed the projected
  catalog to 60 113 tokens against the 60 000 ceiling. The percentage
  fields keep a one-line "needs both dates or never applied" note; the
  date fields drop theirs.

Also acts on the compliance swarm's GDPR Art.32 note: the read-only
lister no longer selects employee names at all (the employee id is what
the per-company decision needs), so the script touches no PII.

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

* docs(mcp): say the jamkning percentage is rejected without both dates

CodeRabbit on #2240: "never applied" described the pre-fix engine
behaviour; the contract now is that a create or update with a percentage
and a missing date is rejected before staging. Same length, so the
tools/list payload budget is unchanged. The concurrency finding is
tracked in #2256 instead of this PR.

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

* fix(mcp): keep tools/list under budget after proforma landed on main

After merging main (#2254 proforma fields) the projected tools/list
measured 60 010 tokens against the 60 000 ceiling with this PR's two
jamkning field notes. Per the budget test's own rule, demote a read tool
instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes
search-only. Versions exist only once a report is rendered for signing
or filing, which is the same switched-off iXBRL path as its sibling
gnubok_get_arsredovisning_filing_status, already search-only since
2026-09-02. Still reachable via gnubok_call_tool.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:22:41 +02:00
Jakob Wennberg 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
Jakob Wennberg 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
Jakob Wennberg 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
Mattsson e2d38b0ab3 fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)
* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)

settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.

- Insert the row between voucher creation and the CAS status update, same
  shape as the bank-match path (amount in invoice currency, transaction_id
  null). An insert failure cancels the voucher and fails closed; both CAS
  failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
  a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
  only where exactly one posted payment voucher exists; zero or several are
  reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
  reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
  the je/invoice index still refuses a double link, and the authenticated
  writer can delete its own row (the CAS-failure path depends on it).

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

* fix(invoices): write the payment row from every mark-paid path and harden the backfill

Skeptic and review round on #2236 (issue #2019):

- One helper (lib/invoices/invoice-payment-row.ts) now writes the
  invoice_payments row for all four transaction-less settlement paths:
  dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
  mark_invoice_paid commit and the v1 mark-paid route, which booked their
  own voucher and never wrote the row. Amount = applied amount (new
  paid_amount minus prior), not cash received, so a 3740 öre absorption
  never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
  transaction_id NULL as "reconciled to a bank line": the bank line for a
  manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
  wall-clock before #1332); refuse rows that disagree with the voucher's
  1510 credit / settlement debit; report partially covered invoices
  (rows_short) instead of patching; record each executed run in
  behandlingshistorik (InvoicePaymentRowBackfilled, migration
  20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.

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

* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)

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

* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event

Swedish review round 2 on #2236:

- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
  it logs at error level with company and row id (a stranded row would
  read as a settlement in the kontantmetod cut-off) and returns whether
  the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
  behandlingshistorik event cannot be written, so rows and change log
  (BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.

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

* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses

Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.

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

* fix(invoices): never backfill a payment row into a closed or locked period

Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.

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

* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)

cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:42:43 +02:00
Mattsson 3159920d7c fix(customers): re-issue the country backfill skipping migration-reset source companies (#2249)
* fix(customers): re-issue the country backfill skipping migration-reset source companies

Migration 20260903170000 (PR #2241) failed on prod at its first UPDATE:
"Archived migration reset source records are immutable" (SQLSTATE P0001).
Rows of a company listed in company_migration_resets are frozen by
trigger, and the backfill touched them, so the whole file rolled back and
prod has neither normalize_country_code() nor country_raw.

The same SQL ships again as 20260903173000 with every UPDATE excluding
those companies (their legacy text keeps being read through
normalizeCountryCode() at runtime). The old file is removed rather than
edited: prod never recorded it, staging was re-tracked under the new
version by hand. References in code, tests and DECISIONS.md follow.

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

* docs(decisions): cite the shipped backfill version 20260903173000

The country-ISO entry still named 20260903170000, the version that never
landed on prod; only the follow-up entry keeps that number, as history.

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

* docs(decisions): drop the duplicate country-ISO entry the merge carried in

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

* docs(decisions): keep a single country-ISO entry

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

* docs(decisions): rebuild the tail from main so the merge leaves no duplicated entries

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:39:56 +02:00
Jakob Wennberg a48508e5b0 feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) (#2255)
* feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219)

Two things from the same Discord report, both in bookkeeping from the
transaction view:

1. After picking a kostnadsställe the field showed only the code ("1").
   DimensionCombobox now writes the value's full name under the field once
   a code is committed, exactly as AccountCombobox does for the account
   name (looked up in the full registry so an archived code stays
   readable). The input text itself stays the code: the blur/revert logic
   keys on it.

2. A self-created dimension could not be removed at all: the DB already
   allowed it (enforce_dimension_registry_guards lets a non-system
   dimension go when no posted/reversed line carries its number, and the
   value retention trigger fires on the cascade), but no route or UI
   asked. New DELETE /api/dimensions/[id]: 400 DIMENSION_SYSTEM_DELETE for
   kostnadsställe/projekt, the guard's own Swedish P0001 verbatim as 409
   DIMENSION_REFERENCED, 404, and a happy path; the register gets a quiet
   "Ta bort dimension" link for the active custom dimension behind a
   DestructiveConfirmDialog. Keys added to sv and en.

Closes #2219

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

* test: satisfy the TypeScript ratchet for two test files main inherited from #2247 and #2242

accounts-route.test.ts built SyncResult literals without the
requestedFromDate / historyNarrowed fields #2247 added (vitest does not
typecheck, so it passed locally); fiscal-periods route.test.ts got two
more one-argument POST(req) calls from #2242 in a file already at its
ratchet baseline. Both files now typecheck; the ratchet runs clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 19:20:09 +02:00
Jakob Wennberg c3b7e79af8 fix(errors): show a route's Swedish message even without a keyword match (#2086) (#2253)
* fix(errors): show a route's Swedish message even without a keyword match (#2086)

getErrorMessage passed a route's free-text `error` / `message` through
only if it contained one of ~30 keywords ("kunde inte", "saknas", ...).
"Inget skattekonto är registrerat hos Skatteverket." has none, so the
skattekonto sync replaced the one sentence that would have helped with
"Ett oväntat serverfel uppstod. Försök igen senare.", which is wrong
advice for a company without a skattekonto. 155 of the 631 message_sv
strings in structured-errors.ts failed the same keyword test.

A second way in: looksLikeUserFacingSwedish accepts a string that reads
as Swedish (å/ä/ö, a strong Swedish word, or two weak function words)
and shows no sign of a technical leak (stack frames, file:line, JS/Node
error vocabulary, Postgres/PostgREST/SQL fragments, JSON, URLs). The
keyword list stays as the first way in. English framework text still
falls through to the status/context fallback, and a registry-wide test
pins that every message_sv now passes.

Closes #2086

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

* test(errors): pin the two call sites whose Swedish messages now pass through (#2086)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:33 +02:00
Jakob Wennberg 1150930cb9 fix(customers): allow 0-day payment terms and say why the field is invalid (#2070) (#2251)
Typing "0" into Betalningsvillkor on a customer made the form silently
unsavable: the form schema had min(1) and no error was rendered for the
field, so the user saw nothing happen. 0 days is a real value (betalning
direkt / vid mottagande), and the invoice schema already accepted it.

Customer and supplier forms now validate whole days 0-365 and show the
rule under the field; the API schemas (customer create/update, supplier)
accept 0 the same way; and every `|| 30` fallback that would have turned
a stored 0 back into 30 on edit or create is `?? 30`.

Closes #2070


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:54:28 +02:00
Jakob Wennberg cc18e9d530 fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237) (#2242)
* fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237)

POST /api/bookkeeping/fiscal-periods decided "first period" as "no period
exists at all", so a company that imported 2024+ from Fortnox and then
created its actual first year by hand (2022-07-22, the registration date)
was refused with the 1st-of-month error, while the DB trigger
enforce_first_of_month_for_subsequent_periods would have accepted the row.

The route now mirrors the trigger: first = no existing period starts
earlier. The 1st-of-month rule (BFL 3 kap. 1 §) keeps binding subsequent
years, and its message now says which years it binds and why instead of
only refusing.

Tests: prepend with a mid-month start passes; a mid-month start for a
non-earliest period is still a 400 that names the rule.

Closes #2237

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

* chore: carry the DECISIONS.md line for this PR in #2247 instead (append-only log conflicts on every merge)

---------

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:41:14 +02:00
Jakob Wennberg d69de86b71 fix(reports): count reversed originals in the KPI monthly breakdown (#2201) (#2243)
The year total (tb / tb_ex_year_end) aggregates posted AND reversed
entries, so a same-year storno nets to 0. The monthly section, both the
get_kpi_report_aggregates RPC and the dimension-filtered JS fallback in
lib/reports/monthly-breakdown.ts, was posted-only: it dropped the reversed
original but kept the storno (itself posted). 10 000 kr on 3041 in March,
reversed in April, gave March 0 kr, April -10 000 kr, year 0 kr, and PR
#2198 made the per-month figures visible enough to add up.

Migration 20260903160000 replaces the RPC with the monthly join on
tb_ex_year_end's entry set verbatim (no extra status predicate); the JS
fallback filters status in ('posted','reversed') the same way. The pg-real
pin ("in tb, not in monthly") is flipped and a storno case asserts
sum(months) = net result. Everything else in the function is byte-identical
to 20260730090000.

Verified: pg-real suite against a rebuilt local supabase/postgres with every
migration applied (9 tests), unit suite, lint.

Closes #2201


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:21:00 +02:00
Mattsson 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
Jakob Wennberg 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
Jakob Wennberg 1e91c126ff fix(worklist): count unbooked skattekonto rows in the Att göra badge and Hem list (#2180) (#2227)
The Transaktioner inbox lists unbooked skattekonto rows next to unbooked
bank rows, but the sidebar badge and the Hem "Att göra" list counted bank
rows only, so a migrated tax account waited in the inbox unseen.

- lib/worklist: new category book_skattekonto with the inbox's own
  predicate (status = 'booked', no verifikat, not ignored); aggregate
  includes it in counts and total.
- Hem: a "Bokföra skattekontohändelser" row under Bokför, deep-linking to
  /transactions?source=skatteverket.
- Sidebar badge hook: the same third head-count query, summed into the
  /transactions badge.
- DashboardNav subscribes to skattekonto_transactions realtime changes;
  migration adds the table to the supabase_realtime publication so a
  booked or ignored row drops the badge without a manual refresh.

Closes #2180


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:17:41 +02:00
Mattsson bc5da12372 fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)
* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds

Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs
in one /register request: cursor://anysphere.cursor-mcp/oauth/callback,
https://www.cursor.com/agents/mcp/oauth/callback and
http://localhost:8787/callback. Only the loopback matched a built-in
pattern and /register fails the whole set on any unknown URI, so every
Cursor connection to the URL we hand out in Settings died with
"Redirect URI not allowed". Users cannot self-register the cursor://
form either (the settings panel requires https).

Add a built-in `cursor` provider with the two non-loopback callbacks as
exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on
the consent page, list the pre-approved clients in the OAuth clients
settings text (sv + en) and the mcp-server rules, and cover the
register, allowlist and consent paths with tests.

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

* fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect

Review findings on #2225, one pass:

- Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the
  string "null", so the consent page emitted form-action 'self' null and
  Chromium would block the 303 to the deeplink after Allow. The header
  now uses the scheme-source (cursor:) when the origin is opaque; a test
  pins the header on the cursor:// URI.
- Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom
  scheme can be claimed by any local app (RFC 8252 section 8.4), so it
  must not be presented as a vendor-verified callback. The deeplink is
  its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with
  the localhost tag "Din egen dator" and verified: false. The https
  cursor.com callback keeps the verified label. A test pins that a code
  minted without a code_challenge can never be exchanged, which is what
  keeps a scheme hijack from turning into a token.
- CodeRabbit (Minor): the rules doc now says the Grok callback matches
  with or without the trailing slash.
- Regression skeptic: docs/WHITELABEL.md listed only Claude and
  localhost and pointed at the wrong file; now lists the built-ins and
  points at lib/auth/oauth-allowlist.ts.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:33:58 +02:00
Jakob Wennberg d900fea1a8 feat(connect): Skatteverket data calls through the connector with a per-company canary (#2209)
CONNECT_SKV_CANARY_COMPANIES mirrors the bank canary: the listed companies'
user-token Skatteverket calls (skattekonto, moms, AGI) go through the
connector's data proxy while this installation still has its own credentials
and keeps refreshing the tokens on its own OAuth client. Callers without a
company id (OAuth start, token exchange, environment reporting) keep the
plain rule: own credentials win. This is how hosted moves its Skatteverket
traffic to Connect a few companies at a time before dropping its own keys.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-03 14:41:11 +02:00
Mattsson 65bd675f43 fix(auth): unlink social identities bound to the old address when the login email changes (#2208)
* fix(auth): unlink social identities bound to the old address when the login email changes

GoTrue keys OAuth identities on the provider subject, so after a secure
email change from A to B the Google identity auto-linked for A stayed on
the account and "Logga in med Google" from the A mailbox still opened the
company (prod 2026-09-03, willemduplessis999 -> levandefisken kept both
Google logins). A change is a change: only identities bound to the
address the user switched from go; the email identity, password, BankID
and social identities on other addresses stay, and Google with the new
address re-links itself on the first sign-in.

Migration 20260903110000 adds a BEFORE UPDATE OF email trigger on
auth.users (next to sync_profile_email) that deletes those identities and
recomputes app_metadata.providers. A trigger covers every completion
path: hook link, stock link, phone click without a session, admin-side
change. pg-real test covers removal, keep-others, case-insensitive match,
email identity untouched, no-op on unchanged email, and other users on
the same address. Applied to staging under the same version.

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

* fix(auth): make the old-identity unlink trigger safe without GoTrue and keep Google-only accounts reachable

Skeptic findings on 5889aec7e:

- The pg-real container has no auth.identities (GoTrue creates it and
  does not run in CI), so the trigger failed every auth.users email
  update there, including the existing profile-email-sync suite. Guard the
  function with to_regclass and bootstrap a GoTrue-shaped auth.identities
  in tests/pg/bootstrap.sql so the trigger's own tests actually run.
- A Google-only account (no password, no email identity) ended with zero
  identities after the change, and whether Google with the new address
  re-links then depends on GoTrue internals. When the trigger removes the
  last social identity and no email identity exists, it now creates the
  email identity for the new address, the row GoTrue links Google through
  and password recovery resolves. pg-real tests cover both cases.
- Self-hosting note: keep secure email change enabled, since a change now
  also removes the old address's social logins.

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

* fix(auth): verified flag, audit trail and redaction for the old-identity unlink trigger

Second review round on PR #2208:

- Superagent P1: the synthesized email identity claimed email_verified
  for every email update, admin-side included. It is now verified only
  when the pending address became the address in the same write (the
  signature of GoTrue's ConfirmEmailChange); anything else gets an
  unverified identity, as GoTrue itself would create it.
- Compliance swarm A.8.15: removing a login method left no trail. The
  trigger now writes an identity_unlink entry to auth.audit_log_entries
  with the removed providers, old and new address and whether the change
  was confirmed, next to GoTrue's own user_modified entry.
- Compliance swarm A.5.34: the migration comment named real test accounts;
  redacted.
- pg-real: the cross-user test still expected the old-address user to end
  with zero identities; it now expects the email identity the previous
  round introduced. New tests cover the unverified admin path and the
  audit entry.

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

* test(pg): give the CI auth audit table the ip_address column GoTrue adds

pg-real runs against the bare Postgres image, whose auth.audit_log_entries
predates GoTrue's ip_address column (NOT NULL DEFAULT '' on every hosted
project). unlink_old_address_identities writes that column, so all seven
trigger tests failed with 42703 in CI while the same migration ran clean
on staging. Mirror the real shape in the bootstrap instead of changing a
migration that is already applied under this version.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 14:25:32 +02:00
Mattsson fefef038c5 fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys (#2207)
* fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys

Migration 20260902180000_sales_orders_hardening added a composite
(sales_order_id, company_id) foreign key from sales_order_items to
sales_orders next to the original single-column one. PostgREST then saw
two relationships and answered every `items:sales_order_items(*)` embed
with HTTP 300 / PGRST201, so kundorder list, detail, create and the MCP
list tool all failed on prod and staging with "Oväntat serverfel".

- Hint the three embeds with `!sales_order_items_sales_order_id_fkey`
  (route, load service, MCP list tool).
- scripts/checks/ambiguous-embed.mjs only parsed single-column
  `FOREIGN KEY (col)`, which is why the ratchet reported 0 for this pair.
  It now reads composite column lists (named or default constraint
  name) in both CREATE TABLE and ALTER TABLE, derives the same 17
  ambiguous pairs prod's pg_constraint reports, and flags all three
  shipped sites on main.
- Unit tests for the composite shapes: alongside a single-column key,
  replacing one, and inline in CREATE TABLE.

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

* fix(checks): drop composite embed edges when DROP COLUMN removes a member column

Postgres drops every foreign key a column takes part in, so the
ambiguous-embed parser must release a composite edge (and its constraint
name) when one of its columns is dropped, not only the single-column key.
Otherwise a later migration would keep a pair armed for a relationship
that no longer exists and reject valid embeds. Regression case added.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 12:18:47 +02:00
Jakob Wennberg 4c6feea64d feat(connect): bank sync through the connector operation, with a per-company canary (#2205)
* feat(connect): bank sync through the connector operation, with a per-company canary

In connector mode the enable-banking sync no longer pages Enable Banking on
the instance: it calls POST /api/connect/bank/sync on the hosted service with
the session id it holds and the account, and receives booked, normalized
rows plus the raw provider pages to archive. Everything downstream is shared
with the direct path (stored external ids computed here from booking_date,
amount and the account scope; ingest; archive; balance refresh), so a company
that moves to the connector produces byte-identical keys. A 410 from the
service maps onto the same SessionExpiredError the direct path throws.

bankConnectorMode(companyId) gains CONNECT_BANK_CANARY_COMPANIES: listed
companies use the connector even while the installation has its own Enable
Banking credentials, which is how hosted Accounted moves its bank sync to
Connect a few companies at a time before dropping its keys. The contract
package gains the bank sync request/response schemas (2026-09-03).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): calendar-valid dates, body read inside the timeout, service origin as the default

Review follow-ups on #2205. The contract validates date_from, date_to and
booking_date with z.iso.date() (2026-02-30 and an empty booking date are
refused; the installation derives its stored keys from booking_date). The
connector sync reads the response body inside the timeout window so a
service that stalls the body cannot hold the sync open. DEFAULT_CONNECT_BASE_URL
now names the connector service (connect.accounted.se), which is where the
sync operation exists; the hosted app's copy of the connector routes is
legacy and hosted Accounted itself sets GNUBOK_CONNECT_URL explicitly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.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 11:06:47 +02:00
Mattsson 828628d882 fix(auth): land stock email-change links on the status page and stop retries voiding pending mails (#2199)
* fix(auth): land stock email-change links on the status page and stop retries voiding pending mails

A secure email change needs one click in each mailbox. Stock GoTrue links
verify on the GoTrue host and return to /auth/callback through redirect_to
with ?message= (first click), ?error= (dead link) or ?code= (completing
click); none carries a token_hash, so the callback bounced every one of
them to /login with no message. Users read that as a failure and pressed
"Byt" again, and because the claims fast path carries no new_email, the
route re-issued both tokens on every press and voided the links they were
about to click.

- /api/account/email stamps flow=email_change on emailRedirectTo and reads
  pending state from GoTrue when the session claims lack it, so a repeat
  request inside the 30-minute window is a no-op instead of a re-send.
- /auth/callback routes flow=email_change redirects to
  /auth/email-change?status=partial|done|failed; hook-style token_hash
  links keep using the existing verifyOtp branch.

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

* fix(auth): let signed-in stock email-change redirects through the proxy and treat a minted code as done

Skeptic findings on e5639fb43:

- The proxy bounced authenticated /auth/callback requests to / unless they
  carried type=email_change. Stock GoTrue links return with only the
  flow=email_change marker, so the new status branch was unreachable from
  the signed-in browser the change usually starts in. Exempt the marker too.
- A completing click opened in a browser without the PKCE verifier (phone
  mail app) failed the code exchange and, with no session to inspect, was
  reported as a failed change although GoTrue had already flipped the
  address. A code is only minted after that verify, so report done.

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

* fix(auth): gate email-change requests with an atomic per-user claim

CodeRabbit on PR #2199: the pending-state read from GoTrue is not atomic,
so two concurrent POST /api/account/email calls (two tabs, a retried
fetch) could both see nothing pending and both re-issue the confirmation
tokens, voiding each other's mails.

Migration 20260903083000 adds email_change_requests (one row per auth
user, RLS with no policies) and two SECURITY DEFINER RPCs:
claim_email_change_request(p_email, p_window_seconds) is a single
INSERT ... ON CONFLICT DO UPDATE whose row lock serialises concurrent
claimers, so exactly one caller per address per window wins; a different
address always wins; release_email_change_request drops the claim when
GoTrue refuses the change so the user can retry.

The route claims right before updateUser, answers resent:false when the
claim is held, releases on GoTrue failure, and falls through to GoTrue if
the RPC itself errors. pg-real test covers sequential, windowed,
concurrent, per-user, release and RLS behaviour. Applied to staging with
the same version.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:55:12 +02:00
Mattsson 51b68afc87 fix(reconciliation): judge bank sign-off from the fiscal period start and show the refusal (#2200)
A user with a September-to-August fiscal year could not sign off 1930:
the dialog let them press Signera, the server refused, and the dialog
showed "Något gick fel. Försök igen."

Three defects, one flow:

- signOffAccount judged a bank account over the calendar year from
  1 January (the getAccountStatus default) while the page the signer
  looked at was scoped to the fiscal period. The sign-off now resolves
  the fiscal period covering through_date and judges from its start,
  for every caller (dashboard, v1, MCP, pending-operation executor).
- The dialog decided whether the "sign anyway" override was needed from
  the page tile, which can be scoped to a narrower range. It now
  previews the exact sign-off with dry_run on open and on every date
  change, and NOT_RECONCILED carries the unexplained amount in
  details so the warning can name it.
- The routes passed the refusal through getErrorMessage(), which did
  not know the sign-off codes and replaced the Swedish text with its
  generic fallback. The codes are now in the structured error registry
  with a thrown_message_sv flag: the mapper passes the thrower's text
  (dates, amounts) through verbatim and English users get message_en.


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

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:11:42 +02:00
Mattsson 1c82baf553 feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)
* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor

The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the
deadlines page selected invoices by status alone. A sent proforma past its
due date was chased with a betalningspaminnelse and flipped to 'overdue',
and it appeared as a receivable. All four now filter document_type =
'invoice', which is also the precondition for adding quotes (offert): a
quote carries a date but never a receivable.

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

* feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion

Adds document_type 'quote' with valid_until, quote_status (open / accepted /
declined; expired is derived from valid_until, never stored) and
quote_decided_at. Quotes are numbered OF-nnn at insert from
company_settings.next_quote_number via generate_quote_number(), the same
pattern as delivery notes, so a declined quote never leaves a hole in the
F-series the way a proforma does. The column next_quote_number already
existed on prod and staging without a migration; the migration adopts it.

Engine: build-invoice-write writes the quote columns and keeps
remaining_amount at 0; the draft editor refuses accepted or declined
quotes; PATCH refuses changing a quote's or delivery note's document type
since the number belongs to the series; mark-paid refuses quotes.

New POST /api/invoices/[id]/quote-status records the decision and locks
once an invoice exists. Conversion is extracted into
lib/invoices/convert-to-invoice.ts (one implementation for the route and
the MCP staged commit, which had drifted): a converted quote stays and
flips to accepted, the invoice links back via converted_from_id and gets
its due date from the customer's payment terms; a declined or already
invoiced quote is refused. next-number previews the OF-series for quotes.

Migration applied to the staging branch and registered as 20260902140000;
the pg test runs in CI (pg-real).

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

* feat(invoices): quote PDF, email and filename surfaces

The customer-facing surfaces get a quote sibling for every proforma branch:
PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the
due date, a notice that the document is not an invoice or a payment
request, and no payment box, OCR, bankgiro, Swish, QR or payment link.
The email says the quote is attached and valid until the expiry, drops
the payment details and pay-online button, and asks about the quote
rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT
number and payment accounts are skipped for quotes as for proformas:
a quote is not a faktura under ML 17 kap.

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

* feat(invoices): offert in the editor, list and detail pages

Editor: "Offert" document type with a required "Giltig till" field
(default today + 30 days) in place of the due date; the wire body mirrors
it into due_date so the shared schema is satisfied. Payment link, ROT/RUT,
periodisering and the bank box are already gated on real invoices. The
type cannot be switched on an existing quote (its OF-number belongs to
the series).

List: an Offerter tab beside Proforma, "Ny offert" in the split button,
and a status column that shows the decision or the derived expiry:
Utgången and Avböjd are exception chips, Öppen and Accepterad muted text.

Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow
menu; an expired quote asks before accepting or invoicing (bypassable);
once an invoice exists the page links to it as Fakturerad and hides the
decision actions. Strings in both sv and en.

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

* feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface

MCP: create_invoice takes document_type quote with a required valid_until
and allocates the OF-number at insert; the convert tool keeps its id and
accepts quotes with the registry refusal codes; new set_quote_status;
list_invoices and get_invoice expose valid_until and the effective quote
status, including a derived expired filter. The tools/list payload stays
under its ceiling without a ledger change. The MCP staged convert now
uses the shared converter.

v1: POST /invoices/{id}/quote-status (registered in the endpoint registry,
scope map and route loader), valid_until and quote_status in the list,
create and detail shapes, and a quote_status list filter. Skill atoms
mention offert. Decision log lines for the own number series, derived
expiry, accepted-not-cancelled conversion and the header action layout.

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

* test(invoices): pass route params and period id in the new quote tests

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

* refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them

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

* fix(invoices): close the quote review findings in one pass

Skeptics (correctness, compliance, regression) and CodeRabbit on #2163:

- quote_status is no longer a write-builder output, so a v1 PATCH or MCP
  update_invoice can never reset a recorded accept/decline; new quotes are
  opened by the invoices_quote_defaults trigger (20260902141000), which
  also keeps due_date and valid_until equal. v1 PATCH and the MCP update
  executor now use the shared editable-draft predicate.
- One live invoice per converted source, enforced by a partial unique
  index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and
  both quote-status routes compare-and-set on the decision they read.
- MCP-created quotes carry remaining_amount 0; mark-paid, transaction
  match and voucher link refuse non-invoices on the MCP staging tools,
  the executors and the dashboard link route.
- Conversion of a foreign-currency source refetches the rate for the
  conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one;
  0-day payment terms mean due on receipt.
- bulk-create refuses quotes per item; list_invoices rejects a
  quote_status filter combined with another document_type; an omitted
  document_type on PATCH means unchanged.
- attention, push notifications, open-AR count, FX revaluation, year-end
  and accrual auto-detect and bank-match suggestions only read fakturor.
- Quote PDF and email print Summa / Total instead of Att betala.
- Regenerated skills/accounted-api for the new v1 endpoint.

Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY
on the migrations (repo precedent, 13.8k rows, transactional apply);
re-validating VAT treatment at conversion (the converted invoice is a
draft the user reviews; follow-up).

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

* fix(invoices): second review round: migration versions, order links, batch allocation, races

- Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its
  own 20260902141000 to prod while this PR was in review and prod's head
  moved past both files; below-head versions are skipped by branching,
  which would have left the quote trigger off prod. Staging rows renamed.
- Quote lines never carry sales_order_item_id (an offer must not count as
  invoiced kundorder quantity); the converter carries a proforma line's
  order link onto the invoice.
- Converter compare-and-sets the source (proforma cancel, quote accept):
  a concurrent cancel, proforma-to-order conversion or decision removes
  the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a
  second document for the same sale.
- MCP set_quote_status gets the same compare-and-set as the HTTP routes;
  0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere.
  quote-status (dashboard, v1, MCP) accepts valid_until so an expired
  sent quote can be reopened, as the docs promised.
- MCP mark-paid refuses only quotes, parity with the dashboard route
  (a sent proforma marked paid is a supported prepayment record).
- Batch allocation (dashboard route and MCP tool) refuses non-invoices
  before the RPC, which gates on status alone.
- Customer AR drill-down, v1 customer open invoices and archive guard,
  and the calendar feed read fakturor only.
- Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the
  editor locks the document type on existing quotes and delivery notes.

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

* chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline

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

* fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms

- 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a
  live converted invoice exists (the compare-and-set in the three decision
  writers could still be beaten by a conversion landing in between); the
  routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED.
  generate_quote_number now also requires a non-viewer membership so a
  viewer's session token cannot burn OF-numbers through PostgREST.
- Converter checks quote eligibility before the Riksbanken call and treats
  a failed company_settings read as a failure instead of a 30-day default.
- Re-sending the same decision keeps quote_decided_at (idempotent).
- gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its
  write sibling; the dashboard link route surfaces a failed lookup.
- Late-fee and credit-term texts never print on a quote.
Applied and registered on staging; pg tests added.

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

* fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK

- match-batch surfaces a failed document lookup instead of allocating.
- v1 quote-status dry-run preview carries the new valid_until.
- Quote PDF heading reads Offertinformation / Quote information.
- 20260902222000 also pins the date invariants the trigger maintains as a
  CHECK: a quote always has valid_until = due_date, nothing else has one.
  Applied on staging.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:40:25 +02:00
Jakob Wennberg 3f9a21c64d refactor(connect): validate the Peppol connector with the shared contract schemas (#2193)
The hosted Peppol route now parses requests with the schemas published in
@accounted/connect-contract instead of its own copies, and the instance
transport validates every hosted answer against the contract's response
schemas (a shape mismatch is a non-retryable protocol error) and reads the
error envelope through connectorErrorSchema. Paths come from the operation
table. No behaviour change for a conforming peer; the two sides can no longer
drift apart silently.

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.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-02 21:08:49 +02:00
Jakob Wennberg f31eeaa603 feat(connect): Peppol through the connector (hosted proxy, instance transport, ownership ledger) (#2177)
* feat(connect): peppol connector foundation: capability, ledger/budget service, quota

Adds the storage + package shape for brokering Peppol through the connector with
the same one-address + rate-budget model as bank/skatteverket: a peppol
capability (connector-gated, free on hosted), peppol as a ledger + upstream
service, a conservative rate budget, and a migration extending the ledger
service CHECK and the per-key limits (peppol_connections_per_company). Proxy
route + instance-side Qvalia reroute follow. Switch-on gated on the Qvalia
brokering-terms check.

(cherry picked from commit 3cc0da6a3, migration renumbered 20260902190000)

Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat

* feat(connect): Peppol through the connector: hosted proxy, instance transport, ownership ledger

Completes the Peppol upstream for self-hosted instances on the connector
(WS3): an instance with a connector key carrying the peppol scope and no
Qvalia keys of its own sends and receives e-invoices through Arcim's
contracted access point, the same way bank and Skatteverket already route.

Hosted: app/api/connect/peppol/[...path] speaks the PeppolTransport
operations (lookup, submit, status, evidence, recipient PUT/DELETE, inbound
list/xml) rather than proxying Qvalia paths, because the Qvalia account is
shared by every hosted company and every instance: reads must be scoped to
what the caller owns, and the inbound read endpoint is destructive for the
whole account. Ownership: a receiving registration is a ledger row (service
peppol, participant id in account_uids, sha256 in handle_hash so one key
holds a participant at a time); outbound submissions land in the new
connector_peppol_submissions table and gate status/evidence; inbound
documents are served from the hosted archive filtered by the participants
the key holds. Per-company quota (peppol_connections_per_company), the
shared PEPPOL_RECEIVING_MAX_REGISTRATIONS cap, and the global peppol rate
budget apply. Provider failures cross as CONNECTOR_UPSTREAM_ERROR with the
adapter's retryable flag (422 or 502).

Instance: lib/invoices/transports/connector.ts implements PeppolTransport
over that API and registers itself in connector mode (key present, no
QVALIA_* keys); getPeppolTransportAvailability() defaults to it when no
provider is selected, so an instance needs no PEPPOL_TRANSPORT_PROVIDER.
Webhooks are not brokered; the existing outbound status poll covers it.
Hosted is byte-identical: it has its own keys, so connector mode is never on.

Docs: SELF-HOSTING.md, SOVEREIGN.md, .env.example. Switch-on for third-party
instances stays gated on the Qvalia brokering-terms check; without the scope
every operation answers 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): authorize Peppol participants per key, harden the proxy after review

Review follow-ups on #2177. Authorization: a key may only register (and send
as) participant identifiers Arcim recorded on the key at issuance
(connector_keys.peppol_participants, migration 20260902191000) or the
licensee's own org number, and a document may only be submitted as a sender
the key has registered; X-Connector-Company stays an opaque per-company ref.
Cap: the shared access-point cap now counts fresh pending reservations and is
re-checked after this request's own reservation, so concurrent registrations
cannot both pass. Inbound: both halves of the participant id are filtered in
the archive query (over-fetched, then exact-pair checked), so foreign rows
sharing an identifier cannot consume the limit. Delete: deregistration is a
required transport capability, checked before the ledger row is revoked, and
registration refuses an access point that cannot deregister. Instance
transport: the hosted URL must be https (loopback http only, same rule as
getConnectorConfig), and the response body is read inside the timeout window
with body-read failures mapped to retryable transport errors.
issue-connector-key.ts gains --peppol-participants and
--peppol-connections-per-company. Declined: NOT VALID on the ledger CHECK
(the table is empty until keys are issued; the validated scan is instant).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): bind Peppol ownership to the instance company, query exact participant pairs

Second review round on #2177. Ownership is now (key, company_ref), not key
alone: a sender must be registered under the same company header, status and
evidence reads look the submission up under the header company, DELETE and
re-registration refuse a participant the key holds for another company, so
one company on a multi-company instance cannot act on another company's
registration through the shared key. The instance transport resolves the
owning company from its own peppol_deliveries / peppol_registrations rows
before status, evidence and deregistration calls (deps.companyFor,
deps.companyForParticipant, wired in transports/index.ts). Inbound listing
stays key-wide (the instance routes documents to its own companies by its
own registrations). The archive query now runs one exact-pair query per
scheme (scheme fixed, that scheme's identifiers), so neither foreign nor
cross-pair rows can consume the limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.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-02 20:57:57 +02:00
Jakob Wennberg fcfa1ba974 feat(connect): extract the connector wire contract into packages/connect-contract (#2179)
* feat(connect): extract the connector wire contract into packages/connect-contract

The definitions in lib/connect/contract.ts (key prefix, headers, entitlements
path, entitlement and sync-report shapes) move into a standalone MIT package,
packages/connect-contract (published as @accounted/connect-contract), joined
by the error envelope, the stable error codes, and Zod schemas for every
Peppol connector operation (lookup, submit, status, evidence, register,
unregister, inbound list and xml) with an operation table. Shape only: no
behaviour, no provider code. In-repo callers import through the tsconfig and
vitest alias; lib/connect/contract.ts re-exports so nothing else changes.
The point of the package is that either side of the connection can be built
outside this repository: a self-hosted ledger talking to Accounted Connect,
or another connector service talking to the open ledger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* docs(decisions): record the Connect direction, the Peppol proxy shape, and the contract package

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.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-02 20:57:47 +02:00
Jakob Wennberg fc04578818 feat(parties): suggestion pipeline from ledger keys and linked documents (#2172)
* feat(parties): phase 1 substrate, one party per counterpart

Adds the identity layer above customers and suppliers, which keep their
tables and every foreign key and gain a nullable party_id.

- parties: company-scoped identity with status (suggested | confirmed),
  kind, alias keys, origin and merged_into. One live party per org number
  and company, enforced by a partial unique index; merged losers leave the
  index so a merge can be undone. This is the unique key the
  duplicate-invoice guard has lacked, since suppliers never had one.
- party_facts: statements with a source, a rank (preferred | normal |
  deprecated) and two time axes, never overwritten.
- party_identities: bankgiro, plusgiro, IBAN and friends per party, with
  seen and paid counts and a known | unverified status.
- party_decisions: every human action on a party as a labelled example.
- normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts
  (strip separators, drop the century on 12 digits, Luhn check, 10 digits).
- ensure_party(): find by org number inside the company, else create.
  Name-only rows never merge at insert time; a name merge is a recorded
  human decision.
- Backfill: one party per existing supplier and customer, merged on org
  number, suppliers first so both roles land on one party.
- Archive contract: the four tables are master data in the full archive.

Observed parties (keys derived from voucher and bank text) are not stored;
they stay computed by the ledger-context RPC. No posted entry is touched.

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

* feat(parties): observed parties from voucher text, ledger_key and its mirror

Migrants arrive with vouchers, not bank transactions, so the bank-keyed
ledger context is empty for them. This adds the description-keyed twin.

- public.ledger_key(text): legibility key on top of the frozen
  normalize_counterparty_key mirror: strips AP-register prefixes (levfakt,
  leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier
  number that follows them, and trailing 1-3 digit runs, never "inköp".
  Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared
  fixture list in the pg test.
- public.get_observed_parties(company, from_date, limit): posted vouchers
  grouped by ledger_key(description) with occurrences, variants, expense
  and revenue SEK from the lines, first/last seen, median cadence and the
  Laplace-smoothed dominant result account. Excludes storno, opening
  balance, year-end and VAT settlement, and vouchers that carry a bank
  merchant name (those stay with get_ledger_deep_context). SECURITY
  INVOKER, so RLS scopes it. Never stored.
- lib/parties/classify.ts: the deterministic pre-classifier moved out of
  the evaluation script so product and evaluation share one implementation
  (0.965 agreement with the founder labels, party recall 0.99).
- lib/parties/observed.ts: RPC wrapper that classifies each row and
  derives a display rhythm from the cadence.

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

* fix(parties): tenant-safe composite foreign keys on every party link

Facts, identities, decisions, customers.party_id, suppliers.party_id and
parties.merged_into now reference parties(id, company_id), so a row can
only point at a party in its own company. ON DELETE SET NULL names
party_id so role rows keep their company_id. Adds a pg-real test that
rejects every cross-company link and checks company_id survives a party
delete.

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

* feat(parties): suggestion pipeline from ledger keys and linked documents

Phase 1c of the parties plan. Fills a migrant's register with suggested
parties from what the ledger already knows, never as facts:

- get_ledger_key_evidence(company): hard keys per ledger_key from the
  documents linked to posted vouchers (org number via normalize_org_number,
  VAT, bankgiro, plusgiro, printed name). Documents whose supplier org is
  the company's own are the company's sales invoices and only count in
  self_docs.
- apply_party_suggestions(company, user, items): upserts suggestions.
  Attaches by explicit party_id, org number or an exact alias key; never
  by name. Identities become known at two sightings. Idempotent.
- decide_parties(company, user, ids, kind, note): bulk confirm or dismiss
  with one party_decisions row each.
- parties.suggested_reason: the evidence summary the queue shows per row.
- lib/parties/suggest.ts: buildSuggestions (pure) and
  suggestPartiesForCompany. Keys that mix two org numbers keep neither the
  hard key nor identities; same-core live parties are reported as
  similar_to for a person to decide. coreKey() moves into ledger-key.ts.

55 unit tests and 14 pg-real tests pass locally.

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

* fix(parties): decide_parties dismisses suggested parties only

Dismiss is the queue's answer to a suggestion; a confirmed party is never
archived through it. Superagent P2 on #2172.

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

* test(parties): type the rpc mock with its args so the ratchet stays clean

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-02 18:18:12 +02:00
Mattsson c0818bb2d2 feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)
* feat(sales-orders): kundorder with partial delivery and partial invoicing

Adds sales orders (kundorder) as their own non-ledger document between
agreement and invoice, for companies that deliver or invoice in parts.

Schema (20260902130000): sales_orders + sales_order_items with RLS via
user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon
execute), company_settings.sales_orders_enabled UI gate, and back-links
invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced
quantity per order line is DERIVED from the linked invoice lines on
non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so
no counter can drift and a credited invoice frees its quantity. Header
status is draft / confirmed / completed / cancelled; completion is kept
by DB triggers from the same derived quantity. Delivery and invoicing
progress are derived per line, never stored as status.

Service + API: lib/sales-orders (create/update with id-preserving line
replace, transitions with compare-and-set, cumulative delivery
registration, invoice-from-order through buildInvoiceWriteData so
booking stays in the engine, proforma -> order conversion), routes under
/api/sales-orders and /api/invoices/[id]/convert-to-order, structured
SALES_ORDER_* error codes, archive classification of the new tables.
The invoice editor round-trips sales_order_item_id so a draft edit
cannot drop the link; GET /api/invoices gains ?sales_order_id=.

UI: /sales-orders list, create/edit form reusing the invoice line
conventions, detail with deliver and create-invoice dialogs and linked
invoices; nav row behind the settings toggle; the webshop row is
relabelled webshop_orders; "Skapa order" on proformas.

MCP (20260902141000/141001): list/get reads plus four staged writes
(create, transition, register delivery, create invoice from order) whose
executors call the lib services; op types added to the pending
operations CHECK.

Tests: route tests for every route (401/400/404/happy), service unit
tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts
(16 cases, green on staging) covering RLS, numbering guards, the
over-invoice trigger incl. release on cancel/credit and cross-company
refusal, the quantity floor, and completion maintenance.

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

* fix(sales-orders): harden kundorder after skeptic and security review

Resolves every finding from the PR #2166 review pass in one batch.

Order link integrity: replaceInvoiceItems now refuses a line set that
drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK),
closing the MCP update_invoice header-only edit and the v1 PATCH path
that severed the link and freed the quantity for double invoicing. The
update_invoice re-fetch, gnubok_get_invoice and the v1 item projection
now carry sales_order_item_id so well-behaved clients round-trip it.

Quantity math: derived remaining/invoiced quantities are rounded to six
decimals and compared with an epsilon (roundQty, qtyGreater) so a float
remainder such as 0.5999999999999996 can neither refuse the final partial
invoice nor land as an invoice quantity; duplicate explicit picks are
summed before validation.

Leveransdatum: per-line last_delivery_date (migration 20260902160000);
an invoice takes the latest date over the lines it covers and only when
the covered quantity was delivered, never the header date and never for
an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23).

VAT drift: the order stores the customer type and VAT-validation flag its
lines were priced under; invoicing refuses with
SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the
order re-validates the lines. Customer and currency are frozen once
invoices exist.

Tenant and role gates: composite FK (sales_order_id, company_id) ties a
line to its parent's company (Superagent P2); aa_enforce_company_writer_role
on both tables so a viewer cannot write through the browser client.

Proforma -> order refuses proformas with ROT/RUT, periodisering or
negative-quantity lines instead of dropping those fields. RESTRICT FK
errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES.

Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with
reason), regenerated skills/accounted-api (sales_order_item_id on invoice
items), pg tests for the composite FK, the viewer gate and the new
columns, unit tests for every changed path.

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

* fix(sales-orders): resolve CodeRabbit round on PR #2166

Quick wins from the review, all in one pass:

- replaceInvoiceItems fails closed when the invoice_items snapshot cannot
  be read (it is both the restore source and the input to the kundorder
  link guard); the guard branch is explicit in both PATCH routes.
- Cumulative delivery registration carries an optimistic predicate on the
  quantity it read, so two concurrent registrations cannot regress each
  other; DELETE of an order keeps its allowed status in the predicate and
  answers a conflict when zero rows match.
- Business dates (order date, delivery date, invoice date) default to the
  Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the
  delivery date is also the Riksbanken rate anchor.
- The invoice-from-order executor treats an event emit failure as
  non-blocking: the draft already exists.
- sales_order_items are archived through their parent with the order
  currency denormalised, like invoice_items.
- Proforma "Skapa order" tolerates a 2xx without a parsable body; the
  settings toggle refreshes the server-rendered nav.
- List route doc states that q matches the order number (customer names
  are matched client-side).

Declined (out of scope for this PR): moving header + line writes and the
delivery loop into transactional RPCs (same PostgREST pattern as the
invoice PATCH path, tracked as a follow-up), the MCP approval handler's
error message shape (pre-existing code outside this change), and the
docstring-coverage warning (no repo convention).

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

* fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling

- 20260902160000_sales_orders_hardening.sql collided with main's
  20260902160000_parties_substrate.sql after the third sync; renamed to
  20260902180000 and made idempotent (DROP ... IF EXISTS before each
  ADD CONSTRAINT) so a preview branch that applied it under the old
  version replays it cleanly. Staging's schema_migrations row renamed.
- sales_order_items goes back to a direct archive dump: the coverage
  contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a
  table with its own company_id; the currency lives on the parent order
  one file over, joined by sales_order_id.
- Scanner ceiling re-baselined after merging main (parties phase 1): 397.
- v1 PATCH test queues a real empty invoice_items snapshot now that
  replaceInvoiceItems fails closed on an unreadable one.

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

* fix(sales-orders): drop the composite FK before its unique index on replay

The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped
the unique (id, company_id) before the FK that depends on its index, so
the preview branch replay (which had applied the file under its former
version) failed with SQLSTATE 2BP01. Order swapped; replay verified on
staging.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:14:49 +02:00
Jakob Wennberg daeab67dca feat(parties): phase 1 substrate, one party per counterpart (#2162)
* feat(parties): phase 1 substrate, one party per counterpart

Adds the identity layer above customers and suppliers, which keep their
tables and every foreign key and gain a nullable party_id.

- parties: company-scoped identity with status (suggested | confirmed),
  kind, alias keys, origin and merged_into. One live party per org number
  and company, enforced by a partial unique index; merged losers leave the
  index so a merge can be undone. This is the unique key the
  duplicate-invoice guard has lacked, since suppliers never had one.
- party_facts: statements with a source, a rank (preferred | normal |
  deprecated) and two time axes, never overwritten.
- party_identities: bankgiro, plusgiro, IBAN and friends per party, with
  seen and paid counts and a known | unverified status.
- party_decisions: every human action on a party as a labelled example.
- normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts
  (strip separators, drop the century on 12 digits, Luhn check, 10 digits).
- ensure_party(): find by org number inside the company, else create.
  Name-only rows never merge at insert time; a name merge is a recorded
  human decision.
- Backfill: one party per existing supplier and customer, merged on org
  number, suppliers first so both roles land on one party.
- Archive contract: the four tables are master data in the full archive.

Observed parties (keys derived from voucher and bank text) are not stored;
they stay computed by the ledger-context RPC. No posted entry is touched.

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

* fix(parties): tenant-safe composite foreign keys on every party link

Facts, identities, decisions, customers.party_id, suppliers.party_id and
parties.merged_into now reference parties(id, company_id), so a row can
only point at a party in its own company. ON DELETE SET NULL names
party_id so role rows keep their company_id. Adds a pg-real test that
rejects every cross-company link and checks company_id survives a party
delete.

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-02 17:43:06 +02:00
Jakob Wennberg 5291806c37 feat(parties): observed parties from voucher text, ledger_key and its mirror (#2168)
Migrants arrive with vouchers, not bank transactions, so the bank-keyed
ledger context is empty for them. This adds the description-keyed twin.

- public.ledger_key(text): legibility key on top of the frozen
  normalize_counterparty_key mirror: strips AP-register prefixes (levfakt,
  leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier
  number that follows them, and trailing 1-3 digit runs, never "inköp".
  Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared
  fixture list in the pg test.
- public.get_observed_parties(company, from_date, limit): posted vouchers
  grouped by ledger_key(description) with occurrences, variants, expense
  and revenue SEK from the lines, first/last seen, median cadence and the
  Laplace-smoothed dominant result account. Excludes storno, opening
  balance, year-end and VAT settlement, and vouchers that carry a bank
  merchant name (those stay with get_ledger_deep_context). SECURITY
  INVOKER, so RLS scopes it. Never stored.
- lib/parties/classify.ts: the deterministic pre-classifier moved out of
  the evaluation script so product and evaluation share one implementation
  (0.965 agreement with the founder labels, party recall 0.99).
- lib/parties/observed.ts: RPC wrapper that classifies each row and
  derives a display rhythm from the cadence.

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