Commit Graph

11 Commits

Author SHA1 Message Date
Mattsson c0818bb2d2 feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)
* feat(sales-orders): kundorder with partial delivery and partial invoicing

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Quick wins from the review, all in one pass:

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:14:49 +02:00
Mattsson b68c082ef5 feat(bank-sync): close the F2 report: gap backfill, consent and paused chip states, agent-triggered sync (#2165)
* fix(bank-sync): cron backfills the gap since the last successful sync

The daily incremental sync always asked the bank for the last 7 days. Any
pause longer than that (a lapsed subscription paid again, a consent renewed
after expiry, an outage) silently lost the days in between: the connection
came back, looked healthy, and the missing transactions never arrived.

The lookback now widens to cover the gap since last_synced_at plus one day
of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more
asks for strategy=longest like the manual sync route does. Dedup via
external_id makes the overlap harmless. First syncs keep their 90-day path.

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

* feat(bank-sync): chip warns seven days before a bank consent expires

The transactions-page chip only reacted once a connection was already dead
(expired/error) or had gone stale. A consent that is about to end looked
healthy until the morning it stopped syncing. New "expiring" state when a
live connection's consent_expires is within seven days, the same threshold
as the consent-expiry email in the sync cron. Precedence: attention,
expiring, stale, healthy.

getChipState moves to lib/transactions/bank-sync-chip-state.ts so the
precedence is unit-tested; the component keeps the rendering only.

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

* feat(bank-sync): chip says paused when the subscription lapsed

The daily cron filters connections by the bank_sync capability, so a
company whose trial or subscription ended keeps status=active rows with a
frozen last_synced_at. The chip read that as "stale, check the connection",
which sends the user to re-authorise a connection that is perfectly alive.
56 of 191 active connections on prod were in this state on 2026-09-01.

New "paused" state, ranked above everything else, when the company lacks
bank_sync: hosted points at billing, self-host at the connector key, the
same split BankSyncNowButton already makes. getChipState takes an options
object so the clock stays out of render (react-hooks/purity).

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

* feat(api): agent-triggerable bank sync in v1 and MCP

Closes the first wish in the F2 report: an integration could read bank
data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/
{connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner
(extensions/general/enable-banking/lib/trigger-sync.ts).

Cost is bounded structurally, not by policy: the window is never
caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection
synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at
(429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on
instead of retrying), and a failing connection is throttled per process by
attempt time. A dead session is flipped to expired with a remediation that
hands the user the connect link: no API call revives a consent.

Gated on bank_sync like gnubok_connect_bank; scope transactions:write.
Registry, scope map, load-routes, spec snapshot and the generated
accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes
added to the structured-error registry. The web Synka-nu route is left as
is (see DECISIONS.md).

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

* test(bank-sync): use the options object in the remaining chip-state calls

Four multi-line calls still passed the clock positionally after
getChipState moved to an options object; tsc flagged them (vitest did not,
the extra argument was ignored at runtime).

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

* fix(api): address skeptic findings on the agent-triggered bank sync

Three refutations from the pre-publish skeptic pass:

1. Core imported the extension. The v1 sync route pulled the runner
   straight from @/extensions, which the core-build gate rejects and which
   left a live bank endpoint on zero-extension builds. The route now
   resolves it through the registry's services channel against a contract
   in lib/bank-sync/trigger-sync-contract.ts (same pattern as the
   Skatteverket read service) and answers EXTENSION_DISABLED when the
   extension is absent.

2. The idempotency cache stored the handler-level 429. A same-key retry
   after Retry-After, which is the documented retry, replayed the stale
   cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer
   caches 429 responses; regression test added. The endpoint's pitfall no
   longer claims Idempotency-Key is mandatory (it was never enforced).

3. Two cron tests read the clock twice and failed whenever a millisecond
   passed between the reads. They now pin the clock with fake timers.

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

* fix(bank-sync): durable cooldown lease and review wording

Resolves the PR #2165 review findings in one pass.

Superagent P1: the attempt throttle was a process-local Map, so two agent
calls on different serverless instances (or a retry after a cold start on
a failing connection) could each bill an Enable Banking call, contradicting
the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until
(migration 20260902150000), claimed with one conditional UPDATE before the
bank is called; Postgres row locking makes exactly one claimer win, the
rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on
success and failure. Tests cover the claim order, a failed attempt seen
from a second instance, a lost race, and an expired lease.

CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag"
(daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be
today); the cooldown pitfall on the v1 endpoint, the MCP description and
the in-band cooldown instruction now say a cooldown can follow a failed
attempt and tell the agent to compare last_synced_at before deciding.

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

* fix(bank-sync): lease claim as a literal filter for the schema guard

CI's no-phantom-columns guard counts runtime-built query expressions and
its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')`
claim added one. The column now defaults to epoch (NOT NULL), so "never
claimed" is just "expired long ago" and the atomic claim is a single
literal `.lte('sync_lease_until', now)` the guard can check. Migration is
unshipped (same PR), so it is edited in place.

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

* fix(bank-sync): runner verifies company membership before the lease

Superagent (round 3): the MCP path reached the shared runner without a
membership check of its own. Both callers do enforce it upstream
(withApiV1's company resolution and resolveMcpCompanyContext in the MCP
dispatcher), but the runner writes transactions and bills a bank call, so
it now checks company_members itself, before the cooldown and the lease
claim, and answers NOT_FOUND for a non-member. The viewer check that was
buried inside the sync block moves up with it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:17:41 +02: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
Mattsson 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
Mattsson 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
Mattsson 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
Mattsson 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
Jakob Wennberg 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
Jakob Wennberg d8244ecaff feat(mcp): connect_migration: one-click card into the previous-system wizard (#1960)
* feat(mcp): gnubok_connect_migration: one-click connect card into the previous-system wizard

'Jag hade Fortnox' now gets the same feel as Skatteverket: the tool
returns the migration-wizard link for the named provider and renders
the connect-card widget (new migration branch: 'Hämta från Fortnox',
button opens the wizard that logs into the old system and fetches all
fiscal years plus invoices, customers, suppliers and documents). For
visma/bokio (no API export) the instructions order the SIE drop card
first and this card as the complement. Scope companies:read; skill
step 3 points at the tool instead of raw wizard links; ceiling 62.4K
to 63K documented.

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

* fix(mcp): 5-minute freshness hint on tools/list, widgets and prompts: the catalog changes with every deploy

The stateless-client CacheableResult hint on tools/list, resources/read
(widget HTML) and prompts/list was 1 hour. Claude.ai honors it, so for
up to an hour after a deploy the connector served a pre-deploy catalog:
a freshly shipped tool flapped in and out of the tool list depending on
which fetch hit the client cache, and two E2E runs dead-ended on
'tool does not exist' for a tool that was live server-side. These
payloads are static only within one deploy; 5 minutes bounds the stale
window.

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-26 19:19:24 +02:00
Jakob Wennberg cd9fb5b717 feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954)
From the fourth E2E run: the agent correctly refused to reproduce a
104 KB SIE file token by token (silent mid-verifikat truncation) and
dead-ended to the web wizard, and its replies were walls of compliance
prose.

1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the
   pending-document infra; .se/.sie/.si only, 50 MB HTTP cap).
   gnubok_sie_preflight and gnubok_import_sie accept upload_id as the
   byte-exact source, plus optional sha256 (hex of the raw bytes)
   verified on the upload_id/base64 paths so truncation is DETECTED,
   never silent. Inline content above 120k chars is refused with a
   pointer to the upload flow. Scope bookkeeping:write (same intent as
   import_sie).

2. Skill: brevity rule (max ~8 short lines per reply, one warning per
   step, no legal essays), memory-first rule (check what is already
   known before asking the opening questions), the upload-first SIE
   step, and gnubok_explain_voucher_gap after import for skipped
   voucher numbers.

3. CONNECTORS.md starter prompt rewritten memory-first so it stays
   copy-paste ready without the user's own data in it. Plugin v1.2.2.

tools/list ceiling 62K to 62.4K documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:11:11 +02:00
Jakob Wennberg b8605aabfc fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).

- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
  TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
  the client-side panel can bundle it. api-keys.ts re-exports everything,
  so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
  scope (reconciliation has three), shared by the panel and the OAuth
  consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
  hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
  derived from domain and scope id. The "(REST API)" heading suffix is
  computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
  counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
  scopes.

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:35:42 +02:00