Commit Graph
81 Commits
Author SHA1 Message Date
MattssonandClaude Fable 5.1 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
a08bf51ced feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16)

Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR
2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record
"forandringar i bokforingssystemet som paverkar bokforingsposternas behandling
samt nar dessa forandringar infordes", and BFN's commentary names
behandlingsregler (automatkonteringar, fasta procentsatser) and new program
versions as the examples. Until now both changed without a trace.

Audit triggers on the behandlingsregler tables and the import logs:
mapping_rules, booking_template_library, categorization_templates,
salary_payroll_config, sie_imports, bank_file_imports. categorization_templates
learns on every booking (occurrence_count, confidence, last_seen_date), so
those telemetry-only updates are excluded by a WHEN clause the same way the
api_keys request counters are (20260721115701): only real rule changes are
logged. Measured against prod that is roughly 3 800 new audit rows a month
against an audit_log already taking 371 688, so about +1 %.

app_releases is an append-only log of program versions seen in production,
written by the runtime the first time a build answers a request. Vercel exposes
no build hook we can trust to write the row, so /api/version records it inside
after(): the handler returns synchronously and a floating promise could be
frozen before the insert lands, which is how a version log ends up silently
empty. The service client is constructed lazily so the constantly polled public
probe pays nothing once the module guard is set.

Program versions are rolled up per Swedish calendar day in the report. main
takes ~570 merges a month, so one event per version would be on the order of
7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the
~400 events a real company's year contains. The statutory unit is the date, and
the same sentence qualifies the requirement to changes that affect processing,
which a deploy list cannot distinguish anyway. app_releases keeps the
per-version truth for anyone who needs to go deeper.

AuditLogEntry.user_id becomes string | null. The column is nullable and
write_audit_log() falls back to auth.uid(), which is NULL for a service-role or
global write; the company-less salary_payroll_config rows are the first that
routinely hit it, and the read model already coded for it.

Also restores the point citations the 2026-07-27 pass removed while the chapter
was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified
against BFN's consolidated text.

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

* test(pg): fix two fixture bugs in the behandlingshistorik trigger tests

pg-real caught both, and neither is in the migration: the inserts fail
before the trigger is reached.

mapping_rules.rule_type is constrained to mcc_code / merchant_name /
description_pattern / amount_threshold / combined; the test used
'merchant'.

booking_template_library's btl_insert policy requires
current_user_can_write() and company_id = current_active_company_id(),
so the authenticated insert needs a company_members row and a
user_preferences.active_company_id, the same setup
booking-template-hidden.pg.test.ts uses.

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

* test(pg): assert the booking-template audit row inside the user transaction

withUserContext always rolls back, so the audit row the trigger writes
is gone before an outside connection can see it. The trigger fires in
the same transaction as the write, so the assertion belongs there too.
The other cases in this file write on the pool (autocommit) and are
unaffected.

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

* fix(reports): name every build id in the per-day program-version entry

Raised by the compliance review on #2097: the roll-up listed five ids
and a count, which leaves an auditor unable to reconstruct which
versions ran that day. app_releases keeps the full record, but the
report is the surface anyone actually reads. A day is bounded by the
deploy rate (~19), so the full list stays one readable cell.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:31:10 +02:00
MattssonandClaude Fable 5 0406e628e1 fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them

The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").

Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.

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

* fix(invoices): gate issuance on the seller VAT number (skeptic finding)

The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).

Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.

Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
  periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.

The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.

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

* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths

Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.

Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).

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

* test(email): refresh brand-mail snapshots for the coherent VAT fixture

The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:30:25 +02:00
9fe37b85b5 feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended

An API key gets an optional ceiling in SEK. Above it the agent may still stage
the work, it just may not finish it alone: a human approves the same verifikat
in the app. Default is NULL, so every existing key keeps its behaviour and
turning this on is entirely opt-in.

Enforced at the two places an API key reaches the ledger, and at both the
refusal happens BEFORE the point of no return:

- MCP: in commitPendingOperation, before the atomic claim, so the operation
  stays 'pending'. Behind the claim it would be caught by the generic handler,
  marked terminal 'rejected', and the staged verifikat would be gone.
- REST: in journal-entries.commit, before commitEntry, so the draft stays a
  draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run
  refuses too, rather than promising a voucher number the key cannot deliver.

Not enforced inside commit_journal_entry: a RAISE there is swallowed by
engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function
that issues every voucher number.

Operations whose amount is only known during dispatch (batch allocation, bulk
booking, the settlement link paths) fail OPEN behind an explicit allowlist.
Pricing them ahead of dispatch would be a guess, and a wrong guess silently
breaks batch allocation the day someone sets a limit. The allowlist is derived
from what production actually stores: create_voucher carries total_debit on
1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003,
create_supplier_invoice_from_inbox carries total on 208 of 228.

This is a blast-radius cap, not a security boundary. A per-entry ceiling is
defeated by splitting one entry into several, and an LLM will find that, so
UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is
one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the
primitive that actually bounds exposure and is left to a separate change.

The guard is written NULL-first everywhere. An absent, unparseable or
non-positive ceiling always means unlimited, never "block everything".

Agents read their own ceiling from gnubok_get_agent_briefing instead of
discovering it by burning a staged verifikat on a 403.

Changing a ceiling is auditable: it now renders in behandlingshistorik
(BFL 5 kap. 11 §). The audit trigger already fired on the column, but the
report dropped the event because the field was not in its diff map.

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

* chore(skill): regenerate accounted-api skill for the new commit pitfall

apiskill:check is a ratchet: the generated reference must match the endpoint
registry. Never hand-edited.

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

* test(agents): pin the DB default itself, and declare the briefing field required

Two review findings, both real:

- the default test stored an explicit NULL, so it stayed green even if the
  column default changed to a positive ceiling: the one change that would
  silently start blocking every existing key. It now omits the column.
- gnubok_get_agent_briefing documents unattended_commit_limit as always
  present and emits it unconditionally, so it belongs in the output schema's
  required list.

Declined the NOT VALID constraint suggestion, with the reason recorded in the
migration: api_keys is 388 rows / 768 kB in production.

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

* docs(agents): name the TOCTOU window in the REST ceiling check

A security scan flagged that the line sum is read before commitEntry, so a
concurrent write to the draft's lines can post over the ceiling. Real, and
accepted: closing it means enforcing inside commit_journal_entry, where a RAISE
becomes a retryable 500 and destroys the staged operation on the MCP path.

Recorded in the code rather than left implicit, so nobody later mistakes this
for a hard control. A per-entry ceiling is already defeated by splitting, which
needs no race; the cumulative rolling-window limit is the primitive that bounds
exposure.

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

* fix(agents): price the settlement and batch paths that were bypassing the ceiling

A security scan flagged that known money-posting operations fail open, and it
was right. The first cut priced only create_voucher, categorize_transaction and
create_supplier_invoice_from_inbox, on the belief that the batch and settlement
paths computed their totals only inside SQL at dispatch. Production says
otherwise: the staged preview already carries the amount, because it is the
number a human is shown when approving the operation.

Over the last 120 days each of these is present and numeric on 100% of that
type's staged rows:

  link_transaction_journal_entry  transaction_amount  1369 rows
  bulk_book_transactions          tx_sum               273 rows
  link_supplier_invoice_voucher   payment_amount        55 rows
  match_batch_allocate            total_allocated       24 rows
  mark_invoice_paid               total                  3 rows

So a key with a ceiling could post any amount through the four largest
settlement paths. Now priced, and the ceiling applies.

Only reconciliation_match stays unpriced: it carries pair_count, which is a
COUNT. Pricing off that would compare pairs against kronor, which is worse than
not enforcing. link_document_to_voucher and attach_document_to_transaction move
no money at all; the transaction_amount they carry is context, not a posting.

Genuinely unpriceable types still fail OPEN. This control can only ever narrow
what a key does, and a wrong guess at an amount blocks a legitimate commit, so
guessing high would leave an agent unable to work.

Adds a test that walks the whole allowlist, so a typo'd field name cannot
silently make a type unpriceable again: that is exactly the hole this closes.

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

* fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room

The tools/list context-budget bench sits at 65 000 tokens and main now leaves
roughly 20 tokens of headroom. An always-present field on the briefing's output
schema costs about 85, so this addition alone pushed the bench red.

The bench's own note is explicit that the answer is to demote a tool rather than
raise the ceiling, so raising it here would be the wrong trade for a
nice-to-have.

Nothing is lost that matters: the operation is never destroyed when it is
refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs
one round trip and no work. That error already carries both attempted and limit,
and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing
is worth doing once there is budget to spend.

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

* docs(api): spell affärshändelse correctly in the commit pitfall

Fixed in the route's registerEndpoint pitfalls, which is the source; the skill
reference is regenerated from it and never hand-edited.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:53:53 +01:00
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00
MattssonandClaude Fable 5 f43a6653f1 feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date

payment_date drives the booking entry date but was only editable via the
v1 PATCH. Close the gap on both remaining surfaces:

- New staged MCP write tool gnubok_update_salary_run (search-only
  catalog; tools/list budget is at zero headroom) accepting the exact
  v1 PATCH field set: payment_date, voucher_series, notes. Draft-only
  with the same optimistic lock semantics, via a new shared service
  lib/salary/update-run.ts used by both the staging preflight and the
  commit executor.
- Run header UI: payment date on a draft run is now an inline date
  input (prefilled, committed on blur/Enter, snaps back on failure),
  saved through the existing internal PATCH. Read-only once not draft.

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

* fix(salary): op-type migration, calc invalidation on date change, scanner compliance

Consolidated CI + review fix pass for #2041:

- pg-real: add 'update_salary_run' to pending_operations_operation_type_check
  (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1).
- Swedish accounting review: a payment_date change on a draft run now clears
  every roster row's calculation_breakdown (shared service and internal PATCH
  alike), so both book preflights refuse the run until a recalculation has run
  against the new date; skatteavdrag and the AGI redovisningsperiod follow the
  payment month. Staging preview exposes invalidates_calculation and the next
  hint states the clearing.
- no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with
  a documented reason for the inherent patch-shaped UPDATE payload.

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

* fix(salary): close skeptic findings on payment_date editing

Skeptic round 1 refuted two paths; both closed:

- Retry idempotency (correctness): the calculation_breakdown clear was
  gated on new-date-differs-from-stored, so a retry after a partial
  failure (header committed, clear failed) compared against the already
  updated date and skipped the clear forever, leaving a stale
  calculation bookable. The clear is now gated on payment_date being
  SUPPLIED, on all three surfaces (shared service, internal PATCH, v1
  PATCH: the v1 route previously had no clear at all and bypassed the
  invariant).
- Kontantprincipen (compliance): AGI derives its redovisningsperiod
  from period_year/period_month while the verifikat books on
  payment_date, so a cross-month payment_date change could book salary
  in one month and declare it in another. All three edit surfaces now
  refuse a payment_date outside the run's period month with the new
  structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date
  input is min/max-bounded to the period month.
- The internal PATCH update is now optimistic-locked on status='draft'
  (races return 400 instead of silently writing), matching the v1 PATCH
  and the shared service, and the clear cannot fire for a run that left
  draft.

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

* fix(salary): carry book_skattekonto op types through the constraint re-create

The sibling migration 20260830130000 (merged from main) re-created
pending_operations_operation_type_check with book_skattekonto_row and
book_skattekonto_rows. This branch's 20260830150000 sorts after it and
re-creates the constraint wholesale, so its list must be that migration's
superset or the two values would be silently revoked at apply time.

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

* fix(salary): value-validate internal PATCH and grandfather out-of-period dates

Two skeptic follow-ups:

- The internal PATCH now validates values, not just keys: JSON body must
  be an object, payment_date must be ISO (shared ISO_DATE_RE),
  voucher_series a single A-Z letter, notes a string of max 2000 chars
  or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing
  unvalidated can reach the DB through the whitelist.
- Creation does not (yet) couple payment_date to the period month, so a
  legally created out-of-period date must stay correctable. All three
  edit surfaces now allow day adjustments within the run's CURRENT
  payment month as well as the period month (grandfather clause); no
  move can introduce a new wrong month.

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

* fix(salary): resolve migration version collision with delete_draft_invoice

Main's delete_draft_invoice PR landed on the same 20260830150000/150001
versions and also re-creates pending_operations_operation_type_check.
Rename this branch's pair to 20260830160000/160001 (applies last) and
carry delete_draft_invoice through the wholesale re-create so nothing is
silently revoked. Final list = sibling's list + update_salary_run.

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

* docs(salary): regenerate accounted-api skill for the new PATCH pitfalls

apiskill:check byte-compares the generated skill against the registry;
the two pitfalls added to the v1 salary-runs PATCH endpoint made
references/salary-runs.md stale and failed Core Build.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:23 +02:00
MattssonandClaude Fable 5 9f8fa1b692 feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval

Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.

- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
  with an explicit userId param (service-role clients null auth.uid());
  the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
  INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
  route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
  outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
  approval, risk 'high' (both outcomes irreversible, never
  auto-committed), catalogVisibility 'search' (tools/list budget at zero
  headroom)
- delete_draft_invoice commit executor delegating to the shared service,
  plus pending_operations CHECK constraint migration pair
  (20260830100000/100001), risk tier, scope map, Granskning vocabulary
  and sv/en labels

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

* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main

Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.

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

* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint

apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.

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

* fix(invoices): pin staged delete outcome and align v1 risk metadata

Skeptic findings on PR #2036:

- Outcome pin: gnubok_delete_draft_invoice stages
  expected_invoice_number alongside invoice_id; the executor passes it to
  deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
  draft's number changed since staging. An unnumbered draft finalized
  between staging and approval is now auto-rejected with a message naming
  the new number, instead of silently switching from the approved hard
  delete to a makulering. Ops staged without the pin keep legacy
  semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
  the delete_draft_invoice pending-op tier (both outcomes irreversible);
  generated accounted-api docs regenerated.

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

* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision

Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:57:28 +02:00
MattssonandClaude Fable 5 8f68421fef feat(skatteverket): expose skattekonto row booking via MCP staged operations (#2039)
* feat(skatteverket): expose skattekonto row booking via MCP staged operations

Skattekonto READ and RECONCILE were already MCP tools, but BOOKING rows
existed only behind the cookie-session extension routes. This closes the
flow on API-key surfaces:

- New staged MCP write tools gnubok_book_skattekonto_row and
  gnubok_book_skattekonto_rows (batch, 1-200 ids), catalogVisibility
  'search', STAGED_OPERATION_SCHEMA, stage-time bookability gates and
  rule-matched counter-account preview. Staging never books.
- New pending-operation types book_skattekonto_row / book_skattekonto_rows
  (CHECK constraint migration pair, risk tier medium, sv/en labels).
- Commit executor reaches the skatteverket extension through the
  registry-resolved services channel (core never imports @/extensions):
  new commitBookSkattekontoRows service wraps the SAME
  bokforSkattekontoTransactionsBatch helper the HTTP bokfor-batch route
  uses (draft + commit per row via the bookkeeping engine, requireSettled),
  with the approving user id passed explicitly (auth.uid() is NULL on the
  service client).

No booking math or account mapping changed: auth-surface exposure only.

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

* chore(migrations): move book_skattekonto constraint pair after main's newest version

origin/main gained 20260830101500 while this branch was in flight; keep
the new pending_operations constraint migrations sorted after it so they
apply in order.

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

* fix(skatteverket): resolve CI and review findings for skattekonto booking tools

- Inline the skattekonto_transactions select strings at both stage-time
  call sites so the no-phantom-columns scanner can resolve them (the
  shared const pushed the unresolvable-expression count past the
  ceiling and failed Unit tests 3/4).
- Return a fixed public message from the commitBookSkattekontoRows
  batch-level catch instead of the raw exception text; the raw error
  stays in the server log (Superagent P2).
- Add verifikat_description to the staged previews so the reviewer sees
  the exact ledger text the booking helper writes, Skatteverket motpart
  included (Swedish accounting review).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:38:30 +02:00
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00
MattssonandClaude Fable 5 e8aa0670ca feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary

Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).

- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
  (draft gate, roundOre, 0 = nollkorning, display-line refresh); the
  cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
  budget at zero headroom), op type set_run_salary (medium risk),
  commitSetRunSalary executor, payroll:write scope, payroll_month
  loadout + payroll-monthly skill step; update_payslip_line description
  now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
  monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
  pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
  snapshot updated

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

* fix(salary): harden set_run_salary per skeptic + CI findings

- Clear calculation_breakdown when the per-run salary changes so the
  existing book preflights force a recalculation: a run can no longer
  be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
  v1 body schema: closes the unbounded/1e307-overflow path that wrote
  Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
  WRITE is uncallable on Claude.ai (update_customer lesson) while three
  surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
  with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
  committed; matches pre-refactor route behavior) and DB error details
  carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
  salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)

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

* chore(migrations): rename set_run_salary pair past main's newest versions

origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).

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

* chore: retrigger Supabase preview after migration-version repair

The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:14:31 +02:00
17caf9d80a feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview

gnubok_update_invoice items are a FULL REPLACE, had no article fields, and
no MCP tool returned invoice lines, so a quantity fix rebuilt from memory
wrote article_id/revenue_account null and reverted vat_rate to the customer
default: revenue silently moved from the article account (3041) to the
VAT-derived default, invisible in the approval preview.

- gnubok_get_invoice (invoices:read, search-only): header plus every line
  with article_id, revenue_account, vat_rate, dimensions, editable_draft
- gnubok_update_invoice lines accept article_id with the same prefill and
  default-set VAT adoption guard as create; permitted-set VAT gate at
  staging; preview carries the new lines' effective booking and a snapshot
  of the lines being replaced
- commitUpdateInvoice scope-checks staged article ids like create does
- OperationPreview: update_invoice preview (current vs new lines, header
  diffs, totals); create_invoice lines show VAT rate and posting account
- invoicing skill points at the read-before-replace round trip

Closes #1642

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

* fix(mcp): use roundOre for update-invoice preview totals so the ore ratchet stays at baseline

The preview-building code in gnubok_update_invoice introduced five naive
Math.round(x * 100) / 100 occurrences, tripping check:guards
(naive-ore-round 627 vs baseline 622) and failing Core Build on PR #1993.
roundOre from @/lib/money is the sanctioned helper and was already
imported in this file.

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

* fix(mcp): make the invoice round trip lossless for text, ROT/RUT and accrual lines

Skeptic review of #1993 found three round-trip breaks for web-created
drafts edited via MCP (the exact silent-loss class issue #1642 reports):

- Text rows: the update pre-gate and resolveInvoiceLineFromArticle
  rejected quantity <= 0 before looking at line_type, so any draft with
  a free-text spacer row could not be edited at all, and the natural
  agent recovery (drop the row and retry the FULL REPLACE) silently
  deleted invoice content. Text rows are now exempt from the
  quantity/description/unit/price gates (CreateInvoiceItemSchema
  parity), normalized to the zeroed stored shape, excluded from the
  staged totals and the VAT gate (commitCreateInvoice billableItems
  parity), and line_type is declared on both the create and update item
  schemas.

- ROT/RUT: gnubok_get_invoice omitted housing_designation,
  apartment_number and brf_org_number, so an items replace on a ROT
  draft either failed AFTER approval ('Fastighetsbeteckning krävs för
  ROT-avdrag') or, for a schema-conformant agent, silently stripped the
  avdrag and the stored personnummer. The three property columns
  (property identifiers, never the personnummer ciphertext) are now
  returned per line, the deduction fields are declared on the update
  item schema, deduction_type rides on the current_items snapshot and
  the new-lines preview, and a staging-time completeness gate
  (arbetstyp/timmar via validateDeductionLines, fastighetsbeteckning
  for ROT, personnummer availability on the invoice or the individual's
  kundkort) surfaces the failure to the agent instead of the approver.

- Declared-schema gap: revenue_account and the accrual fields were
  accepted on pass-through but undeclared, so a schema-conformant agent
  dropped a manual posting-account override or a periodisering on
  pass-back. They are now declared on the update item schema
  (revenue_account also on create; create deliberately does NOT declare
  deduction/accrual fields because commitCreateInvoice drops them), and
  the approval preview shows ROT/RUT-avdrag and the periodisering
  period per line.

tools/list ceiling check after the two new create-schema properties:
63337 of 63400.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:27:02 +02:00
f08fc2c274 fix(invoices): honour defer_invoice_booking on MCP, REST v1 and inbox convert (#1921)
The #967 "Registrera men bokför inte" setting was only respected by the
dashboard routes. Six other paths decided whether to post the issue-time
verifikat with `accounting_method === 'accrual'` alone, so a company that
had switched booking to the explicit Bokför step still got vouchers posted
at issue through MCP, the REST v1 API and the invoice-inbox convert route:

- lib/pending-operations/commit.ts: send_invoice, mark_invoice_sent,
  create_supplier_invoice_from_inbox executors
- app/api/v1/.../invoices/[id]/send and mark-sent (commit + dry-run preview)
- app/api/v1/.../supplier-invoices POST
- extensions/general/invoice-inbox convert

All of them now call booksInvoicesOnIssue() from lib/bookkeeping/booking-mode,
the helper the dashboard already uses, and select defer_invoice_booking where
the settings projection did not include it. Behaviour for accrual companies
without the flag and for kontantmetoden companies is unchanged.

Tests: one deferred-company case per door (8 new), verified to fail without
the fix. skills/accounted-api regenerated for the changed v1 descriptions.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:18:57 +02:00
MattssonandClaude Fable 5 77cacdcf34 feat(mcp): personal_number on gnubok_update_customer (#1876) (#1890)
gnubok_create_customer takes a personnummer (encrypted before approval)
but gnubok_update_customer did not, so an existing customer whose
personnummer sat in the org-number field could not be corrected via MCP.
The REST PATCH already supports it; this closes the MCP/pending-operations
gap across its three layers:

- tool inputSchema: personal_number (string or null) on the strict
  whitelist. The tool validates the plaintext before any DB read and
  mirrors the REST PATCH semantics: masked echo (********-1234 or
  ********-????) = leave unchanged, explicit null = clear, absent =
  untouched. Setting is refused unless the row ends up as an individual
  (GDPR art. 5.1 c), including via a simultaneous type change.
- CustomerChangesSchema: personal_number_encrypted (nullable, ciphertext
  shape per customers_personal_number_check 20260726110000). The
  plaintext key stays forbidden by .strict() and staging-pii-guard.
- update executor: maps the staged ciphertext onto customers
  .personal_number (set/clear/leave), re-checks the individual-only rule
  against a tampered row, and returns only personal_number_masked.

PII handling: the personnummer is encrypted at staging time
(AES-256-GCM, same path as create); pending_operations params carry only
the ciphertext and the approval preview only the masked form. Idempotency
hashing switches to the masked preview for personnummer-bearing updates
(random-IV ciphertext would break retries); other updates keep their
previous hash identity.

catalogVisibility stays 'search': tools/list is at its 59.95K token
ceiling with zero headroom (see DECISIONS.md).

Fixes #1876

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:35:02 +02:00
dc92fb5c0c fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op (#1852)
* fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op

Feedback seq 261545 (deepCFO): approving a bulk_book_transactions op over
MCP returned BULK_BOOK_UNAUTHORIZED, yet the op vanished from /pending
with nothing booked; the user believed it had been approved.

Two defects:

1. The bulk_book_transactions RPC gates on auth.uid(), which is NULL on
   the cookieless service client every MCP approval runs on, so EVERY
   API-key approval of a samlingsverifikat was refused. New migration
   20260824170000 adds p_user_id, honored only for service_role callers
   (same gate as match_batch_allocate 20260817150000 and undo_sie_import);
   the executor passes the approving user, who is now also the actor
   stamped on the verifikat. pg-real test covers member/spoof/no-JWT/
   grants like the precedent.

2. The dispatcher consumed the op on ANY executor error other than 404/
   409. An authorization refusal happens before any side-effect and says
   nothing about the op, so 401/403 now release the claim back to
   'pending'. The executor maps RPC codes through the structured-error
   registry so 403/404/409 are distinguishable from 400. Every
   CommitResult carries operation_status (pending | committed | rejected
   | failed_partial), exposed on gnubok_approve_pending_operation, so
   agents stop inferring consumption from status 'failed'.

Catalog token ceiling 59.95K -> 60K per the documented ratchet protocol.

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

* fix(pending-ops): revoke anon explicitly on the service-actor bulk_book signature

Default privileges grant EXECUTE on new functions to anon; the pg-real
grants test (mirroring match_batch_allocate) caught it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:32:12 +02:00
1e6e19afe9 feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872)
The residual door existed for the page and the v1 API (#1862) but not for
agents: an MCP client that found a 10 kr bank fee between a selection and its
verifikat had to hand the last step back to the user. gnubok_reconcile_residual
dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap /
direction / skattekonto refusals surface immediately), stages a
reconciliation_residual operation with the would-book verifikat as the
preview, and commitReconciliationResidual links and books on approval.

Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by
storno + unmatch); scope transactions:write like the v1 route. The op type
is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list
verified against the live prod constraint 2026-08-25), and the tool joins
the reconcile_month / close_period loadouts and the reconcile-month skill.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:43 +02:00
7cf15a105f fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account

A transaction with no cash_account_id booked its bank leg on the
hardcoded 1930 from the standard templates and category mappings even
when the company's only bank account is e.g. 1920 (PlusGiro), while the
booking dialogs previewed the right account via the client-side
resolveAccount fallback. resolveSettlementAccount now mirrors that
fallback: with a NULL cash_account_id it lists the company's enabled
cash accounts and, when EXACTLY ONE matches the transaction's currency,
settles there; zero or several candidates keep the 1930 fallback. The
explicit-cash_account_id branch (including its throw-on-error path,
issue #842) is byte-identical. Transaction currency is threaded into
the categorize, batch-categorize, pending-operation edit, MCP staging,
and invoice-inbox call sites; other callers get the SEK default.

Forward-only: historical wrong verifikat are corrected only via the
existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md).

Fixes #1722

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

* test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups

The merge of main (PR #1828) into this branch combined two changes that each
add one query to the categorize commit flow; the strictly ordered queued mock
in the allow_duplicate test needed the cash_accounts listing entry inserted
between the period lookup and the pre-FY clamp lookup.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:26:08 +02:00
8e3015e541 fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.

Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.

Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
  OPEN unlocked fiscal period with entry_date = period_start and appends
  "Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
  verifikationstext. Interior gaps, future dates, and a closed/locked
  first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
  calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
  forsta dag (<date>)" for pre-FY dates instead of proposing a
  pre-registration year; "Skapa rakenskapsar" remains for the other
  no_period cases.

No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.

Fixes #1825


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:50 +02:00
MattssonandClaude Fable 5 9622382579 fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver (#1807)
* fix(mcp): surface the database reason and code behind LINK_TX_DB_ERROR to the approver

gnubok_link_transaction_to_journal_entry failed reproducibly for a customer
on certain incoming payments with a bare LINK_TX_DB_ERROR: the service put
the Postgres message in details.reason, but the code had no structured
entry and the commit dispatcher dropped executor data on failure, so
neither the MCP approve result nor result_data said why.

LINK_TX_DB_ERROR now has a structured entry; the executor appends the DB
reason to the message and sets errorCode; the dispatcher persists and
returns executor failure details (result_data.details, CommitResult.data,
.code); gnubok_approve_pending_operation exposes error_code. The next
failing call tells us which constraint or trigger fired.

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

* fix(mcp): keep tools/list under the context budget (drop approve schema descriptions)

The two output-schema descriptions added for data/error_code pushed the
projected tools/list payload 7 tokens over the ceiling guarded by
payload-size.bench.test.ts. The fields stay; the prose goes.

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

* fix(pending-ops): log loudly when the terminal rejected write fails

Review finding: the rejection branch wrote pending_operations without
checking the result, so a failed write left the row in 'committing' with
the executor error, code and details lost silently. Mirror the finalize
branch: inspect the write result and log with the ids plus the failure we
could not persist; the daily recovery sweep still resolves the row.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:24 +02:00
MattssonandClaude Fable 5 0040cadacc feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

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

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

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

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:07:30 +02:00
13b69a2056 fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)
* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings

Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and
the v1 REST API, but not the MCP path, and the web customer list still
showed a personnummer raw when it sat in org_number.

Personnummer (MCP + every write path):
- gnubok_create_customer gets a personal_number input. Until now it had
  none, so an agent creating a private person either dropped the number
  or put it in org_number, which nothing masks. Encrypted at staging
  (personal_number_encrypted + personal_number_masked; personal_number is
  now a forbidden staging key in staging-pii-guard), the approval preview
  shows ********-1234, commitCreateCustomer stores the ciphertext as-is.
  Idempotency hashes the masked preview (new StageOptions.idempotencyParams)
  because the random-IV ciphertext would make identical retries look like
  payload changes.
- A personnummer-shaped org_number on customer_type=individual is the
  personnummer in the wrong field: it is moved into personal_number
  (encrypted) and org_number cleared, on CreateCustomerSchema (web POST,
  v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer
  for in-flight ops. Only a DIFFERENT personnummer next to personal_number
  is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type
  guard from #1724 is unchanged and now also fires at MCP staging, so the
  user never approves an operation that fails at commit.
- Read side: the web customer list and gnubok_list_customers mask a legacy
  individual row's org_number personnummer instead of showing it raw;
  list_customers exposes personal_number_masked and never the ciphertext.
- scripts/repair-customer-personal-number-in-org-number.ts moves the
  existing rows (dry run: 134 rows across 10 companies on prod); run by
  hand with --confirm after deploy.
- customer-onboarding skill: EF customers follow the #1724 decision
  (individual + personal_number); ROT/RUT section names the real field.

Payment terms (MCP):
- gnubok_create_customer staged `payment_terms || 30`, so
  resolveDefaultPaymentTerms at commit always saw 30 and the company's
  invoice_default_days never reached MCP customers. Resolved at staging
  now, so the preview shows the value the row will get.

tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first,
rationale in payload-size.bench.test.ts). apiskill regenerated; no
migrations.

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

* fix(scripts): literal update payloads in the personnummer repair script

The no-phantom-columns scanner counts a runtime-built update payload as
unresolvable and the ceiling (379) had no headroom; two literal payloads
keep the guard able to resolve both branches.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:17 +02:00
e6c4fe2cf8 fix(customers): personnummer guard + personal_number on v1 + payment terms from settings (#1724)
* fix(customers): stop personnummer landing unmasked as org_number, persist personal_number on v1, default payment terms from settings

Closes #1707. Closes #1708.

Personnummer (#1707, Discord kalletoxic):
- CreateCustomerSchema rejects an org_number shaped like a Swedish
  personal identity number on business customer_types. Only
  customer_type=individual rows are masked in lists, so accepting one
  stored an unmasked personal identifier (GDPR art. 5.1 c). The shape
  check uses the month-position rule (legal-entity orgnr always
  carries >= 20), so real orgnr can never false-positive.
- The v1 create, v1 PATCH and bulk-create endpoints accepted
  personal_number through the shared schema but silently dropped it.
  They now store it encrypted, expose it masked (********-1234) on the
  single-customer surfaces, and treat the masked form as unchanged,
  mirroring the internal routes.
- Route-level guards on both PATCH routes (new 400
  CUSTOMER_ORG_NUMBER_IS_PERSONAL) plus a client-side message in
  CustomerForm (sv + en).

Payment terms (#1708, Discord kalletoxic):
- New resolveDefaultPaymentTerms: provided value, else
  company_settings.invoice_default_days, else 30. Wired into the UI
  new-customer dialog, the internal POST, v1 create (incl. dry-run),
  bulk-create and the MCP staged create_customer.

apiskill regenerated; no migrations.

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

* docs: record what the CI build OOM actually was

main raised the build heap to 8192 in parallel with this branch, so the
fix itself is already in and this keeps it untouched. What was missing
is the diagnosis.

Measured with tsc --noEmit --extendedDiagnostics, type-checking the repo
needs 4 192 550 K at 506d030b and 4 187 096 K on this branch, 5 MB less
and 0.26% more instantiations. So the ceiling is the type-check pass at
steady state against Node 20's ~4 GB default old-space, not bundle
growth and not any single PR. Worth writing down so the next person who
sees "Ineffective mark-compacts near heap limit" does not go looking for
it in their own diff.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:41:24 +02:00
Mattsson 43cde6deb9 fix: unignore transactions during categorization (#1683)
Fixes #1660
2026-08-19 11:00:02 +02:00
MattssonandClaude Fable 5 cfdddb2d7e feat(mcp): customer_number on create_customer + Beta tags on webshop surfaces (#1677)
* feat(mcp): accept customer_number on gnubok_create_customer

Parity with gnubok_update_customer: a customer number no longer needs a
create-then-update two-step with two approvals. The staged params carry
the trimmed number, commitCreateCustomer inserts it, and the payload-size
ceiling is bumped 59.7K to 59.75K with a documented entry (the property
has no description; name + maxLength are the whole contract).

Requested by a user on Discord 2026-08-16.

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

* feat(ui): mark webshop integrations and orders tab as Beta

WooCommerce and Shopify rows on the import page get a quiet Beta chip
next to the title, and the webshop /orders sidebar item sets the
existing betaBadge flag. Chip recipe matches the nav beta badge so
Beta reads identically everywhere.

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

* fix(mcp): enforce customer_number invariants and show it on the approval card

Consolidated resolution pass for PR #1677:
- skeptic (correctness): maxLength 32 was advertisement-only on the create
  path; now enforced with a runtime guard in gnubok_create_customer execute
  (clean errors for non-string and >32) and a 400 guard in
  commitCreateCustomer, matching the web/v1 routes and commitUpdateCustomer.
- skeptic (correctness): CustomerPreview never rendered the staged
  customer_number, leaving the approver blind to the new field; added a
  conditional Kundnr row.
- CodeRabbit: reset the event bus in create-customer.test.ts beforeEach.
- Tests cover both new guards at the tool and executor layers.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:46:34 +02:00
15a1c96292 fix(matching): bring the MCP match-invoice commit path to parity with the dashboard route (#1646)
commitMatchTransactionInvoice fed the raw bank amount into
planInvoicePayment with no currency conversion and no absorbOreRounding,
both of which the dashboard and v1 routes do. Consequences: an exact
whole-krona settlement of an ore-carrying invoice was rejected as
MATCH_AMOUNT_EXCEEDS_REMAINING, and a cross-currency match would have
recorded SEK figures in invoice-currency columns.

Parity changes, mirroring the v1 route block:
- FX resolution: refuse foreign rows with no SEK value
  (MATCH_INVOICE_TX_FX_RATE_MISSING), convert via the Riksbanken spot
  rate on the payment date, refuse when no rate exists
  (MATCH_INVOICE_FX_RATE_UNAVAILABLE).
- planInvoicePayment gets absorbOreRounding on pure-SEK settlements.
- The accrual clearing entry is now built by the shared
  buildInvoicePaymentClearingLines helper (same as dashboard/v1), so the
  3740 oresavrundning line and 3960/7960 FX-diff lines exist and 1510 is
  credited at the invoice's booking rate. Dimension re-propagation
  included. Failure semantics preserved (no fiscal period still
  soft-fails like the old builder).
- invoice_payments.exchange_rate records the payment-date rate on
  cross-currency matches.

Reported via gnubok_feedback 2026-07-24 (codex/hermes: 14 875 SEK against
exactly 14 875 outstanding rejected as exceeding remaining).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:21:18 +02:00
3841ab9f54 feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)
gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat
links as a single pending operation, addressed by voucher_series /
voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk
receipt-migration jobs where N separate tools mean N separate approvals.

Staging resolves every row server-side and returns a per-row hit or miss,
so a systematic offset such as a wrong fiscal_year is visible before
anything is approved rather than after N approvals. Only resolved rows
enter the staged operation.

The WORM precondition and the document lookup are shared with the
single-document executor through precheckDocumentLink: a bulk call must
enforce exactly the invariants N single calls would, and a second copy of
a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the
first is hardened.

A batch that links nothing returns 409 instead of a committed no-op.
Partial skips stay committed, but an approval-gated operation on
räkenskapsinformation must not leave an audit record asserting a run that
changed nothing.

The tool is search-only: a one-off migration tool does not belong in the
default catalog every session pays for in context, and keeping it there
pushed the tools/list projection past the 58.5K token ceiling that
payload-size.bench.test.ts guards.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:52:58 +02:00
MattssonandClaude Fable 5 caa0c3b41d feat(mcp): accept article_id on gnubok_create_invoice lines (#1638)
* feat(mcp): accept article_id on gnubok_create_invoice lines

Invoice lines staged via MCP can now reference a catalog article
(artikelregister). Staging prefills description, unit, unit_price,
vat_rate and revenue account from the article with explicit-wins
semantics, mirroring the web line picker. Unknown, foreign-company and
deactivated articles are refused at staging, as is a price prefill from
an article priced in another currency. The approval executor gains a
company-scope gate for staged article_id values: the FK on
invoice_items.article_id proves existence, not tenancy.

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

* fix(mcp): adopt article VAT rate only from the customer's default set

Web line picker parity (InvoiceEditor's applyArticle guard): an article's
stored vat_rate is its domestic rate. The prefill previously staged it
unconditionally, and the staging/commit gates check the wider permitted
set (which includes 25/12/6 for taxed-where-performed supplies), so
{article_id, quantity} to a validated EU business staged 25% Swedish VAT
onto a reverse-charge invoice. The customer is now fetched before the
prefill and the article rate is adopted only when it is in the
customer's default rate set; foreign-business lines fall back to the 0%
reverse-charge/export default unless the agent sets vat_rate explicitly.
Prefill logic extracted into resolveInvoiceLineFromArticle (CodeRabbit).

Found by the skeptic review pass (two independent refutations) and the
Swedish accounting compliance bot, all converging on the same defect.

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

* fix(invoices): name the article VAT adoption set in vat-rules, not the MCP server

The vat-rate-gate-parity guard pins that no invoice write path mentions
getAvailableVatRates: gating on the picker default is the bug it exists
to prevent. The article-rate adoption in gnubok_create_invoice needs the
default set for a different purpose (prefill, not gating), so the
semantics move into lib/invoices/vat-rules.ts as
getArticleVatRateAdoptionSet(), with tests pinning that adoption is
empty for single-rate foreign customers and always a subset of the
permitted set. server.ts keeps gating on getPermittedVatRates.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:42:51 +02:00
MattssonandClaude Fable 5 86f0b70fdd fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

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

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

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

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

* fix(mcp): address review findings on account_override

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 01:24:48 +02:00
MattssonandClaude Fable 5 05380ddf54 feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

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

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

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

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

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

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

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

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

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

* chore: re-trigger CI after Vercel infra hang

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:32:41 +02:00
Mattsson 857dd575d0 fix: harden kontantmetod year-end cutoff (#1592) 2026-08-13 16:53:19 +02:00
ce6efdb3dc refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)
* refactor(pending): one pending-op-owned preview for chat, /pending and flow views

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

Now there is one owner:

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

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

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

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

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

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

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

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

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

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

---------

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

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
2026-08-13 15:55:01 +02:00
c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

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

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

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

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:50:03 +02:00
MattssonandClaude Fable 5 8d56219c31 fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever

A matched inbox item only left the active inbox when
created_journal_entry_id was stamped, and only categorizeTransactionCore
stamped it. Booking the matched transaction through any other path (the
/book dialog route, bulk-book, link-to-existing-voucher) or matching a
receipt to an already-booked transaction (receipt hunt approvals,
attach-document, match-transaction) left the item "linked" forever,
pointing at a transaction that had already left the transactions work
list. Todays hunt fix (#1524) turned this July-old gap into a visible
flood of stuck items.

Two-part fix, because stamps alone cannot cover the reported case:
created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book
samlingsverifikat only one of N matched items can ever carry it.

Write side: lib/transactions/inbox-underlag.ts is the shared
implementation all paths now call. It links matched items' documents to
the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the
verifikation) and stamps created_journal_entry_id best-effort (CAS on
null, unique_violation tolerated). Wired into categorize-core (replacing
its inline block), /book, bulk-book, linkTransactionToJournalEntry, both
attach paths (REST + pending-operation), and the inbox match-transaction
handler. The attach paths and the doc-conflict guard also resolve
bulk-booked transactions through transaction_voucher_links, which they
previously treated as unbooked.

Read side: GET /items (and /items/:id) enrich matched-but-unstamped
items with matched_transaction_journal_entry_id, and the workspace
derives "booked" from it. This is what clears the stuck rows already in
prod without a status backfill, and what covers the N-1 samlingsverifikat
items the UNIQUE constraint refuses to stamp. Bulk-book selection
filters exclude such items so "Bokfor valda" no longer offers 409 fodder.

scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs
the historical document->verifikat links the old paths never made.

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

* fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik

Both from the Swedish accounting compliance review.

The consumed-stamp is now conditional on the underlag actually
referencing a verifikat: stamping over a failed document link hid the
item from the .is('created_journal_entry_id', null) query forever,
leaving a posted verifikation without its underlag reference
(BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed
link now leaves the item unstamped so re-runs and the backfill can
finish the job; a document preserved on another verifikat still counts
as settled.

The backfill script now appends an InboxUnderlagBackfilled event per
repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass
repair touching underlag-to-verifikat linkage leaves a changelog trail
distinguishing it from the original booking action.

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

* refactor(inbox): backfill writes behandlingshistorik through the shared appender

From the Swedish accounting compliance review round 2: a hand-rolled
processing_history insert in the backfill script could drift from the
shared row shape and skip the PII validation. appendProcessingHistory
now delegates to appendProcessingHistoryWithClient, which takes a
caller-supplied service-role client, so standalone scripts write
behandlingshistorik through the exact same code path as the app
(BFNAR 2013:2 kap 8: one reconcilable change log across writers).

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

* fix(inbox): leave the item unstamped when its document belongs to another verifikat

Swedish accounting review round 3: refusing to steal the document was
right, but stamping the item consumed anyway hid the fact that the
transaction's own verifikat ended up with no underlag reference from it
(BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves
created_journal_entry_id null so the mismatch keeps surfacing for
reconciliation, same posture as a failed link.

Also documents in the backfill script header why its writes cannot land
in locked periods: linkToJournalEntry's UPDATE is guarded by the
enforce_period_lock DB trigger, which fires for service-role writes too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:49:20 +02:00
MattssonandClaude Fable 5 8443062b1f fix(vat): enforce fraction unit for supplier invoice vat_rate writes (#1385)
Closes the remaining #310 write paths: credit-note item copies (web, v1,
pending-operations) and arcim-migration supplier imports now normalize
vat_rate to the decimal-fraction unit before storage, and a NOT VALID
CHECK constraint guards every new supplier_invoice_items row. Customer
invoice items deliberately stay percent; legacy supplier rows are left
untouched so posted-entry reversals reuse the exact original values.

Fixes #310

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:49:46 +02:00
Mattsson 9e54a8e400 fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com>
2026-08-02 20:44:59 +02:00
Jakob WennbergandClaude Opus 5 27ae59040e fix(transactions): retire stale invoice match pointers when an invoice settles (#1313)
* fix(transactions): retire stale invoice match pointers when an invoice settles

potential_invoice_id / potential_supplier_invoice_id are write-once import
suggestions: nothing revisited them once written. With recurring same-amount
invoices, an earlier suggestion pointed transaction A at invoice X, X was then
paid off by transaction B, and A kept pointing at a fully paid invoice. The
match dialog computed its amount diff against that invoice's 0 kr
remaining_amount and reported a bogus partial payment, and the dead pointer
also blocked a fresh suggestion: both re-suggestion scans require the column
to be NULL.

Add one shared helper, clearSettledInvoiceSuggestions(), that nulls a settled
invoice's own suggestion column on every other transaction of the same
company, scoped by company_id and by that invoice id only, never widening to
the confirmed invoice_id / supplier_invoice_id links. It is best effort by
construction: every caller has already booked a payment verifikat, so a failed
cleanup logs and returns instead of failing the settle.

Wired into every path where an invoice reaches paid through a payment:
the dashboard and v1 match-invoice / match-supplier-invoice routes, the
dashboard and v1 mark-paid routes, settleInvoicePayment, the batch allocation
route (per fully settled allocation), linkInvoiceToVoucher and
linkSupplierInvoiceToVoucher, linkTransactionToJournalEntry, and the MCP
staged-operation executors for mark_invoice_paid and
match_transaction_invoice. Partial payments are deliberately left alone: a
partially paid invoice is still matchable. The v1 supplier match route also
clears its own row's hint, which it was missing next to its dashboard twin.

Read-time revalidation stays as the backstop for the paths not wired up here.
countSuggestedMatches now delegates to listSuggestedMatches, which already
revalidates candidates, so the worklist badge can no longer claim a number the
list refuses to render.

A data-only backfill migration retires the pointers already stranded in the
database. It touches no journal entry, verifikat or period-locked data, is
idempotent, and its status lists mirror lib/invoices/matchable-statuses.ts.

Fixes #1259

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

* fix(transactions): wire the MCP batch allocation into the settled-pointer cleanup

Review follow-up on the #1259 fix.

commitMatchBatchAllocate calls the same match_batch_allocate RPC as the
dashboard route, and gnubok_match_batch_allocate is a live staged MCP tool, so
an agent settling a samlingsbetalning reproduced the issue exactly: the RPC
nulls potential_invoice_id / potential_supplier_invoice_id only on the source
transaction, leaving every other transaction of the company pointing at an
invoice the batch just closed. The per-allocation loop moves into
clearSettledBatchAllocationSuggestions() so the HTTP route and the MCP executor
run the same code and cannot drift again, with a commit-path test pinning that
only the fully settled allocation is retired.

The enlarged badge scan is made safe. countSuggestedMatches now feeds up to 200
ids into listSuggestedMatches, past the 150 per .in() that countInboxDocuments
already chunks for, so the candidate lookups are chunked at IN_CLAUSE_CHUNK too
and their ids deduped. Both lookups now check .error: previously a 414, a 500 or
an RLS change produced empty maps, an empty list and a zero badge with nothing
logged. Every failure branch here logs companyId, matching the logAndZero
convention.

Also: restore the anchorSupplierInvoiceDocument doc comment above its own call
in the dashboard supplier-invoice mark-paid route (the #1259 block had been
inserted between them), and assert the transaction update payload in the v1
match-supplier-invoice test, which now covers the potential_supplier_invoice_id
null that the route was missing next to its dashboard twin.

Fixes #1259

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:52:24 +02:00
Jakob WennbergandClaude Opus 5 f3bf50d862 fix(invoices): roll back the header row when a recurring-schedule item replace fails (#1312)
* fix(invoices): roll back the header row when a recurring-schedule item replace fails

PATCH /api/invoices/recurring/[id] and the update_recurring_schedule commit
executor wrote the schedule header first, then replaced the items. An item
insert failure restored the items snapshot but left the header update
committed, so a combined edit half-applied: a new day_of_month or
default_dimensions stayed while the line edit was undone.

Both write paths now go through one shared helper,
lib/invoices/apply-recurring-schedule-update.ts, which snapshots the header
before writing it (only for a combined edit, the only case with something to
undo) and compensates it on any items failure. The rollback update is filtered
on the updated_at stamp our own write produced, so a concurrent writer (the
hourly cron, a second edit) wins instead of being clobbered from a stale
snapshot: audit finding C2 in lib/invoices/voucher-matching.ts.

A compensation that itself fails is no longer swallowed. The helper reports
itemsRestored / headerRestored, logs the unrecoverable rows and the intended
restore payload, and both call sites then return the new
INVOICE_RECURRING_UPDATE_PARTIAL registry entry, which tells the user in
Swedish that the schedule may be half-saved and to check fields and items
before retrying. A clean rollback keeps the PG-mapped error so a CHECK
violation still surfaces its specific message.

Also in the rewritten block:
- the items DELETE error is checked, so a failed delete no longer proceeds to
  an insert that would duplicate every line;
- the 404 existence check moved above every write, so a PATCH with items for a
  missing or cross-tenant id writes nothing;
- the items snapshot uses select('*') with id/created_at stripped on restore
  (same idiom as replaceInvoiceItems), so a column added later is carried
  through instead of silently dropped;
- NewRecurringScheduleDialog unwraps the nested { error: { message } } envelope
  the route returns, which otherwise reached the toast as "[object Object]".

The cron's no-empty-items invariant holds on every failure path: the items are
either untouched, restored, or the failure is reported explicitly.

Fixes #1275

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

* fix(invoices): never write when the compensating snapshot is unavailable

Follow-up on the recurring-schedule rollback: the helper still performed two
writes it already knew it could not compensate.

- The header snapshot read now checks its error and a missing row, and the
  header UPDATE is skipped entirely when either holds, so no header change is
  committed that we already know can never be rolled back.
- An unreadable item snapshot now aborts BEFORE the delete (rolling the header
  back) instead of deleting first and reporting itemsRestored: false, so the
  cron invariant "a schedule always has items" holds on every failure path.
- That header read now runs whenever items are replaced and is scoped by
  company_id, so it doubles as the ownership proof the schedule_id-only item
  delete/insert lacks (the commit executor runs with RLS off). Stated in the
  JSDoc as well.
- The item snapshot is paginated via fetchAllRows: a schedule with more than
  1000 lines could otherwise restore partially while reporting a clean
  rollback.
- The executor now returns errorCode INVOICE_RECURRING_UPDATE_PARTIAL,
  surfaced as CommitResult.code and persisted as result_data.error_code, so a
  staged-op caller can detect the partial state without substring-matching the
  Swedish sentence.
- Route: details keys are camelCase throughout, and an item failure is logged
  once, with the repair context kept on the partial path only.

Tests: the unreadable-snapshot branches are exercised (including the
previously unused itemsSnapshotError harness hook), and the test that pinned
"header written with no possibility of rollback" now asserts that nothing is
written at all.

Fixes #1275

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:50:56 +02:00
MattssonandClaude Fable 5 f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00
Mattsson 321e684523 Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
2026-07-22 18:49:57 +02:00
Jakob WennbergandClaude Fable 5 3e1ea29d02 fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an
irreversible voucher or persist a credit note and then run later fallible
steps. A failure there previously marked the whole op status=rejected,
hiding the posted entity and its id from operators.

- new migration 20260722134114: add failed_partial to the
  pending_operations status CHECK and treat it as terminal in both
  immutability triggers (immutable, undeletable, never re-claimable)
- PartialCommitError + ExecutorResult.partialPostedIds carry the posted
  ids; the dispatcher writes status=failed_partial with
  result_data.posted_ids and returns code=partial_commit
- instrument only the two named executors; hoist the read-only
  settlement-account resolution above the storno in the match executor
- consumer sweep: status union + query schema widened, failed_partial
  folds into the Avvisade tab with a badge and posted-ids detail line,
  bulk/reject routes and MCP tools message it explicitly, worklist and
  expiry sweep intentionally untouched (not pending work)
- tests: pg-real coverage for the new terminal semantics, dispatcher unit
  tests for both partial paths plus byte-for-byte regression guards

Fixes #842

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:33:49 +02:00
Jakob WennbergandClaude Fable 5 25a7261eda fix(pending-ops): recovery sweep for operations stuck in committing (#843) (#1108)
The commit dispatcher claims an op with an atomic pending -> committing
CAS; if the process dies after side-effects post but before the terminal
committed write (or that write fails, the PR #841 log line), the row sat
in status='committing' forever: the expire cron only sweeps 'pending'.

Add lib/pending-operations/recover-stuck-committing.ts, invoked from the
existing daily expire cron (no new vercel.json entry):

- Only rows whose updated_at (the claim timestamp: the CAS bumps it via
  the update_updated_at_column trigger) is older than 15 minutes, well
  past the 300s Vercel function ceiling, so in-flight executors are
  never raced.
- Positive evidence that side-effects posted finalizes the row to
  committed with result_data.recovered=true. Evidence exists only where
  params identify a target with an unambiguous posted state:
  categorize_transaction (is_transaction_booked RPC, skipped for
  allow_duplicate), link_transaction_journal_entry (exact tx+entry
  link), match_transaction_invoice (invoice_payments pair row).
- No evidence: terminal rejected with an explanatory result_data,
  never back to pending (re-execution could duplicate side-effects
  that posted without a trace). Reason 'stuck_committing' is distinct
  from 'expired' so the UI badge never claims these rows.
- Every terminal write is CAS-guarded on status='committing'; probe
  errors skip the row for the next run.
- One structured 'pending_op_recovery' warn per row (count by outcome);
  runbook comment added next to the #841 finalize-failure log line.

Tests: unit coverage for the decision logic and cron wiring (401, sweep
invoked, failure isolation), plus a pg-real test proving row selection,
the trustworthy updated_at anchor, committing -> terminal transitions
through the real immutability/input-frozen triggers, and the
is_transaction_booked evidence substrate.

Fixes #843

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:30:33 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
Jakob WennbergandClaude Fable 5 30771b1619 feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)
* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

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

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:00:53 +02:00
MattssonandClaude Fable 5 0e9cca2750 Add/customer mcp (#1055)
* feat(mcp): kontoplan account tools + verifikat notes exposure

Two gaps reported by an MCP-driven user: no account management in the
API, and verifikat notes invisible to agents (they exist in the product
but MCP could neither read nor write them).

- add staged gnubok_create_account / gnubok_update_account (BAS 2026
  prefill for catalog numbers; rename/VAT-default/SRU/activate via
  update; both LOW risk reference data)
- add staged gnubok_set_voucher_note (notes-only annotation, legal on
  posted entries per the 20260608120000 trigger carve-out) and return
  entry_notes from gnubok_query_journal
- new pending_operations types create_account / update_account /
  set_voucher_note (CHECK migration + validate companion, applied to
  staging)
- tools/list payload ceiling 54K -> 56K (documented; wire contract,
  descriptions trimmed first)

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

* fix(skatteverket): unstick BankID connect flow and stale connection views

- respond to the OAuth callback immediately and run the post-connect
  refresh after the response (next/server after()): users no longer
  stare at Skatteverket's consumed consent page for up to 40s
- open the consent flow in a full tab instead of a 600x750 popup that
  hid the approve button below the fold
- disable connect buttons while the OAuth tab is open (parallel flows
  overwrote oauth_state + the PKCE verifier) and recover via a
  closed-tab watcher plus a delayed status refetch
- persist MISSING_SCOPE token health from the post-connect sync and
  show an actionable "approve all permissions" notice
- refetch connection state on tab visibility (settings connect panel,
  enable-banking panel, /skattekonto) so a connect completed in another
  tab or after a mobile app-switch shows up without a manual reload;
  fix /skattekonto never clearing its not-connected state

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

* feat(article-form): add article number field with validation to ArticleForm

* feat(account): enforce account type consistency with BAS class and add validation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:13:53 +02:00
Jakob WennbergandClaude Fable 5 5b8e3fa130 fix(vat): enforce decimal vat_rate on supplier invoice items and normalize MCP percent extraction (#1049)
Supplier invoice items store vat_rate as a decimal fraction (0.25) while
customer invoices use integer percent (25). The shared Zod schema accepted
0-100, so a percent-shaped vat_rate silently booked 2500 % VAT via
line_total * vat_rate, and the MCP inbox-conversion path staged the AI
extraction's percent-integer vatRate straight into the decimal column with
per-line vat_amount 0. Part of #310.

- CreateSupplierInvoiceItemSchema.vat_rate is now a literal union of the
  statutory decimal set (0, 0.06, 0.12, 0.25) with a unit-hint error,
  covering the cookie route, the invoice-inbox convert route, and /api/v1
  (whose runtime ALLOWED_SV_VAT_RATES guard stays as defense in depth).
- New shared normalizeVatRateToDecimal() in lib/vat: percent-shaped values
  (25, 12, 6) divide by 100, results snap to the legal Swedish set, and
  anything else (foreign 19/20, non-finite) maps to 0.
- gnubok_create_supplier_invoice_from_inbox normalizes vatRate at the
  extraction boundary and derives per-line vat_amount when the extraction
  carries none, so the staged header vat_amount is honest.
- The pending-operation executor normalizes staged vat_rate on insert, so
  rows staged before this fix cannot book percent-scaled VAT.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:28:36 +02:00
f8033cb32d fix(transactions): bind manually-fed transactions to a cash account (#1016) (#1017)
* fix(transactions): bind manually-fed transactions to a cash account (#1016)

create_transactions inserted rows with cash_account_id = null, so ledger
accounts fed via MCP/CSV without a PSD2 feed (e.g. 1935 Wise SEK) had no
kassakonto: get_reconciliation_status 404'd with "Okänt kassakonto" and the
"Matcha mot befintlig verifikation" dialog fell back to 1930.

No schema change: cash_accounts.bank_connection_id is already nullable and
source='manual' already exists (every company is seeded a manual 1930). This
is the creation-side leg of the #985-#987 root cause: the resolution chain was
fixed, but manually-fed accounts never got the cash_account_id link.

- Add ensureManualCashAccount (lib/cash-accounts/service.ts): find-or-create a
  manual (source='manual', bank_connection_id=null) cash_accounts row for a
  ledger slot, tolerating the (company_id, ledger_account) UNIQUE race.
- Add an optional ledger_account hint (^19xx) to gnubok_create_transactions;
  commitCreateTransaction resolves/creates the manual account and sets
  cash_account_id on the inserted row.

Reconciliation and voucher matching then resolve the real account unchanged.
Forward-looking; historical cash_account_id=null remediation stays in #1001.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

* fix(cash-accounts): guard ensureManualCashAccount against currency mismatch (CodeRabbit #1017)

The existing-row lookup matched only on (company_id, ledger_account) and
returned the row id ignoring currency, so a SEK transaction hinting at a ledger
already claimed for USD would bind to the wrong-currency cash account. Since
that pair is UNIQUE (one currency per ledger), a mismatch is a real conflict:
throw instead of silently mis-binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

---------

Signed-off-by: Alexander Reinthal <email@reinthal.me>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-16 16:15:06 +02:00
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00