Commit Graph
8 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
MattssonandClaude Fable 5 f216a60bf8 feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning

User request: rabatt i procent per artikelrad, and a marking field
separate from Er referens.

- invoice_items.discount_percent (0-100, default 0): line_total and
  vat_amount are stored NET of the discount. Shared exact-ore math in
  lib/invoices/line-amounts.ts (gross, discount, net) used by the web
  builder, staged-operation commit, editor preview, PDF, and Peppol.
  Undiscounted lines keep the legacy unrounded qty*price byte-identical.
- ROT/RUT deduction computes on the discounted net line total.
- invoices.invoice_marking: printed on the PDF next to the references
  and mapped to Peppol BT-10 BuyerReference (marking wins over
  your_reference; either satisfies the BT-10 requirement).
- Peppol renders the discount as a BG-27 line AllowanceCharge
  (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount).
- Editor: "Lagg till rabatt" in the row menu (same reveal pattern as
  ROT/RUT), Markning row next to Er referens, forval chip, review
  dialog shows discounts and marking.
- Plumbed through v1 REST projections, MCP create/get/update invoice
  tools, pending-operations update path, and copy-invoice (discount
  copied; marking deliberately not, it is recipient-specific).

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

* fix(invoices): carry discount_percent through every deduction, credit, convert and preview path

Skeptic + CI findings on the discount/marking feature, one pass:

- generateRotRutLines and propose-send-lines now pass discount_percent
  into computeDeduction: the send/credit/cash verifikat booked 1513 on
  the GROSS line while deduction_total, the PDF and the Skatteverket
  claim carried the net, stranding the difference on 1513 and pushing
  1510 negative once the customer paid. Test pins 1513=3000/1510=7000
  for a 20%-discounted 10 000 kr ROT line.
- preview-pdf route accepts discount_percent (net totals + net-based
  deduction) and invoice_marking; the editor now sends the marking, so
  the preview equals the invoice it becomes.
- Credit notes carry discount_percent (buildCreditNoteItem, v1 credit
  route select+insert, MCP credit executor) and invoice_marking, so the
  kreditfaktura face arithmetic multiplies out and shows the Rabatt
  column (ML 17 kap 24 §).
- Proforma->invoice convert copies discount_percent + invoice_marking:
  the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH
  and lost the rebate on the next builder pass.
- Editor hides the discount menu in self-billed mode (the self-billed
  wire shape has no discount; previewed net would book gross).
- MCP staging and commitCreateInvoice reject a non-number
  discount_percent (a string coerced past the range check but was
  ignored by the totals math and still stored).
- Regenerated skills/accounted-api (apiskill:check CI failure).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:34:06 +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
Jakob Wennberg d39a9719a3 fix(peppol): say Peppol send is gated per company, never absent (#546) (#2021)
Peppol sending has been live since #1780 behind a per-company access grant, but the MCP skills, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send / :mark-sent descriptions still told agents it did not exist. Every text now says gated per company (requested under Installningar > Fakturering) and keeps the restrictions explicit: aktiebolag senders, standard invoices only, Swedish org-number buyers, no MCP or v1 Peppol send verb yet, :mark-sent as the recovery step when a network-accepted send fails issuance. The skills guard test pins the truthful claim across all surfaces. Includes the regenerated agent_atom_registry seeds and skills/accounted-api references. Refs #546
2026-08-30 12:19:03 +02:00
Jakob Wennberg 523fba0419 feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated.
2026-08-30 11:54:47 +02:00
3447da027a feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

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

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

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

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

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-27 17:35:47 +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
11b82cbb91 feat(api): installable accounted-api agent skill + openapi-to-skill generator (#1516)
* feat(api): installable accounted-api agent skill + openapi-to-skill generator

Three layers, per the July/August 2026 agent-skills ecosystem (skills.sh /
npx skills add, as used by Stripe/Cloudflare/Supabase for their APIs):

- skills/openapi-to-skill/: generic, installable skill that turns any
  OpenAPI spec into a consumer-side integration skill, with a portable
  stdlib-only inventory/condenser tool and an output template + quality
  checklist encoding the distill-not-restate methodology.
- skills/accounted-api/: the installable skill for our own API, rendered
  deterministically by scripts/api-skill/generate.ts from the v1 endpoint
  registry + hand-authored overlays (auth, conventions, domain gotchas).
  CI gate: npm run apiskill:check (core-build.yml).
- lib/api/v1/registry.ts: generateOpenApiSpec now emits requestBody (incl.
  multipart binary parts) and path parameters, and the Zod converter learned
  .default()/z.record()/.pipe()/.transform(), so the public spec carries
  request contracts instead of prose-only.

Docs: /docs/api landing + /llms.txt now point agents at the skill install;
corrected the stale test-key description in the landing (test keys read
real data and force dry-run writes; they are not sandbox-company bound).

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

* fix(skills): escape backslashes in markdown table cells (CodeQL js/incomplete-sanitization)

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-11 12:45:19 +02:00