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>
This commit is contained in:
Mattsson
2026-09-02 18:14:49 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c40e63e3f7
commit c0818bb2d2
80 changed files with 11247 additions and 32 deletions
@@ -0,0 +1,401 @@
/**
* Unit tests for the kundorder (sales order) MCP tools: registration,
* scope/risk/catalog wiring, the staging-time refusals, and the dry-run
* previews. The lib/sales-orders loaders are mocked; the commit executors
* are covered in lib/pending-operations/__tests__/sales-order-executors.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers'
import type { SalesOrder, SalesOrderItem } from '@/types'
vi.mock('@/lib/sales-orders/load', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/sales-orders/load')>()),
loadSalesOrder: vi.fn(),
fetchInvoicedQuantities: vi.fn(),
}))
vi.mock('@/lib/sales-orders/write', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/sales-orders/write')>()),
hasOpenInvoices: vi.fn(),
}))
import { loadSalesOrder, fetchInvoicedQuantities } from '@/lib/sales-orders/load'
import { hasOpenInvoices } from '@/lib/sales-orders/write'
import { tools, isDefaultCatalogTool } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
const byName = (name: string) => tools.find((t) => t.name === name)!
const ORDER_ID = '00000000-0000-4000-8000-0000000000aa'
const CUSTOMER_ID = '00000000-0000-4000-8000-0000000000bb'
const ITEM_ID = '00000000-0000-4000-8000-0000000000cc'
const TEXT_ID = '00000000-0000-4000-8000-0000000000ce'
function makeItem(overrides: Partial<SalesOrderItem> = {}): SalesOrderItem {
return {
id: ITEM_ID,
company_id: 'company-1',
sales_order_id: ORDER_ID,
sort_order: 0,
line_type: 'product',
description: 'Konsulttimmar',
quantity: 10,
delivered_qty: 0,
unit: 'tim',
unit_price: 100,
discount_percent: 0,
vat_rate: 25,
line_total: 1000,
article_id: null,
revenue_account: null,
dimensions: {},
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
invoiced_qty: 0,
remaining_qty: 10,
...overrides,
}
}
function makeOrder(overrides: Partial<SalesOrder> = {}): SalesOrder {
return {
id: ORDER_ID,
company_id: 'company-1',
user_id: 'user-1',
customer_id: CUSTOMER_ID,
order_number: 'OR-7',
status: 'confirmed',
source_invoice_id: null,
order_date: '2026-09-01',
requested_delivery_date: null,
last_delivery_date: null,
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
your_reference: null,
our_reference: null,
notes: null,
default_dimensions: {},
confirmed_at: '2026-09-01T00:00:00Z',
completed_at: null,
cancelled_at: null,
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
customer: makeCustomer({ id: CUSTOMER_ID, name: 'Testbrand AB', default_payment_terms: 30 }),
items: [makeItem()],
delivery_progress: 'none',
invoicing_progress: 'none',
...overrides,
}
}
const WRITE_TOOLS = [
'gnubok_create_sales_order',
'gnubok_transition_sales_order',
'gnubok_register_sales_order_delivery',
'gnubok_create_invoice_from_sales_order',
]
const READ_TOOLS = ['gnubok_list_sales_orders', 'gnubok_get_sales_order']
beforeEach(() => {
vi.clearAllMocks()
})
describe('sales order tools: registration', () => {
it('registers all six tools with strict input schemas', () => {
for (const name of [...READ_TOOLS, ...WRITE_TOOLS]) {
const tool = byName(name)
expect(tool, name).toBeDefined()
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties, name).toBe(false)
}
})
it('keeps the list read in the default catalog and the rest search-only (tools/list budget)', () => {
expect(isDefaultCatalogTool(byName('gnubok_list_sales_orders'))).toBe(true)
expect(isDefaultCatalogTool(byName('gnubok_get_sales_order'))).toBe(false)
for (const name of WRITE_TOOLS) expect(isDefaultCatalogTool(byName(name)), name).toBe(false)
})
it('maps reads to invoices:read and writes to invoices:write', () => {
for (const name of READ_TOOLS) expect(TOOL_SCOPE_MAP[name], name).toBe('invoices:read')
for (const name of WRITE_TOOLS) expect(TOOL_SCOPE_MAP[name], name).toBe('invoices:write')
})
it('every write stages (staged output schema + prose) and carries the expected risk tier', () => {
for (const name of WRITE_TOOLS) {
const tool = byName(name)
expect((tool.outputSchema as { required?: string[] })?.required, name).toContain('staged')
expect(tool.description, name).toMatch(/stag(e|ing)/i)
expect(tool.annotations.readOnlyHint, name).toBe(false)
}
expect(OPERATION_RISK_TIERS.create_sales_order).toBe('low')
expect(OPERATION_RISK_TIERS.transition_sales_order).toBe('low')
expect(OPERATION_RISK_TIERS.register_sales_order_delivery).toBe('low')
expect(OPERATION_RISK_TIERS.create_invoice_from_sales_order).toBe('medium')
})
})
describe('gnubok_list_sales_orders', () => {
it('returns qualified ids with derived progress and pagination', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: ORDER_ID,
order_number: 'OR-7',
status: 'confirmed',
customer_id: CUSTOMER_ID,
order_date: '2026-09-01',
requested_delivery_date: null,
last_delivery_date: '2026-09-02',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
customer: { name: 'Testbrand AB' },
items: [{ id: ITEM_ID, line_type: 'product', quantity: 10, delivered_qty: 4, sort_order: 0 }],
},
],
count: 1,
})
vi.mocked(fetchInvoicedQuantities).mockResolvedValue({ ok: true, byItem: new Map([[ITEM_ID, 10]]) })
const result = (await byName('gnubok_list_sales_orders').execute(
{ status: 'confirmed' }, 'company-1', 'user-1', supabase as never,
)) as { sales_orders: Record<string, unknown>[]; count: number; total_count: number; has_more: boolean }
expect(result.count).toBe(1)
expect(result.total_count).toBe(1)
expect(result.has_more).toBe(false)
expect(result.sales_orders[0]).toMatchObject({
sales_order_id: ORDER_ID,
order_number: 'OR-7',
customer_name: 'Testbrand AB',
delivery_progress: 'partial',
invoicing_progress: 'full',
line_count: 1,
})
expect(result.sales_orders[0]).not.toHaveProperty('id')
expect(fetchInvoicedQuantities).toHaveBeenCalledWith(supabase, [ORDER_ID])
})
it('rejects an unknown status before any DB call', async () => {
const noop = { from: vi.fn() }
await expect(
byName('gnubok_list_sales_orders').execute({ status: 'shipped' }, 'company-1', 'user-1', noop as never),
).rejects.toThrow(/Invalid status/)
expect(noop.from).not.toHaveBeenCalled()
})
})
describe('gnubok_get_sales_order', () => {
it('returns the decorated order with qualified line ids and its invoices', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder() })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1', invoice_number: null, status: 'draft', invoice_date: '2026-09-02', total: 500, currency: 'SEK' }] })
const result = (await byName('gnubok_get_sales_order').execute(
{ sales_order_id: ORDER_ID }, 'company-1', 'user-1', supabase as never,
)) as Record<string, unknown>
expect(result).toMatchObject({ sales_order_id: ORDER_ID, status: 'confirmed', item_count: 1 })
expect((result.items as Record<string, unknown>[])[0]).toMatchObject({
sales_order_item_id: ITEM_ID,
invoiced_qty: 0,
remaining_qty: 10,
})
expect((result.invoices as Record<string, unknown>[])[0]).toMatchObject({ invoice_id: 'inv-1', status: 'draft' })
})
it('maps SALES_ORDER_NOT_FOUND onto a not-found error with a remediation hint', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: false, code: 'SALES_ORDER_NOT_FOUND' })
await expect(
byName('gnubok_get_sales_order').execute({ sales_order_id: ORDER_ID }, 'company-1', 'user-1', {} as never),
).rejects.toThrow(/not found.*gnubok_list_sales_orders/i)
})
})
describe('gnubok_create_sales_order', () => {
it('dry run: previews totals from the shared line math and stages nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeCustomer({ id: CUSTOMER_ID, name: 'Testbrand AB', customer_type: 'swedish_business', vat_number_validated: false }) })
const result = (await byName('gnubok_create_sales_order').execute(
{
customer_id: CUSTOMER_ID,
items: [
{ description: 'Konsulttimmar', quantity: 10, unit: 'tim', unit_price: 100, discount_percent: 10 },
{ line_type: 'text', description: 'Enligt offert' },
],
dry_run: true,
},
'company-1', 'user-1', supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
// 10 x 100 net of 10 % = 900; 25 % VAT = 225.
expect(result.preview).toMatchObject({ customer_name: 'Testbrand AB', subtotal: 900, vat_amount: 225, total: 1125, currency: 'SEK' })
expect((result.preview.items as Record<string, unknown>[])[0]).toMatchObject({ line_total: 900, vat_rate: 25 })
expect((result.preview.items as Record<string, unknown>[])[1]).toMatchObject({ line_type: 'text', line_total: 0 })
expect(findCalls('pending_operations', 'insert')).toEqual([])
})
it('refuses a VAT rate outside the customer permitted set (same gate as the service)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeCustomer({ id: CUSTOMER_ID, customer_type: 'swedish_business', vat_number_validated: false }) })
await expect(
byName('gnubok_create_sales_order').execute(
{ customer_id: CUSTOMER_ID, items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 7 }], dry_run: true },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/INVOICE_CREATE_VAT_RULE_VIOLATION/)
})
})
describe('gnubok_transition_sales_order', () => {
it('refuses cancel while invoices exist (SALES_ORDER_HAS_INVOICES)', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder() })
vi.mocked(hasOpenInvoices).mockResolvedValue({ ok: true, open: true })
await expect(
byName('gnubok_transition_sales_order').execute(
{ sales_order_id: ORDER_ID, action: 'cancel', dry_run: true }, 'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_HAS_INVOICES/)
})
it('refuses a transition the current status does not allow', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder({ status: 'confirmed' }) })
await expect(
byName('gnubok_transition_sales_order').execute(
{ sales_order_id: ORDER_ID, action: 'confirm', dry_run: true }, 'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_INVALID_STATE/)
})
it('dry run: previews confirm on a draft', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder({ status: 'draft', confirmed_at: null }) })
const { supabase } = createQueuedMockSupabase()
const result = (await byName('gnubok_transition_sales_order').execute(
{ sales_order_id: ORDER_ID, action: 'confirm', dry_run: true }, 'company-1', 'user-1', supabase as never,
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.risk_level).toBe('low')
expect(result.preview).toMatchObject({ current_status: 'draft', new_status: 'confirmed', order_number: 'OR-7' })
})
})
describe('gnubok_register_sales_order_delivery', () => {
it('refuses delivered_qty above the ordered quantity', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder() })
await expect(
byName('gnubok_register_sales_order_delivery').execute(
{ sales_order_id: ORDER_ID, lines: [{ sales_order_item_id: ITEM_ID, delivered_qty: 11 }], dry_run: true },
'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_OVER_DELIVERED/)
})
it('refuses delivery on a draft order', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder({ status: 'draft' }) })
await expect(
byName('gnubok_register_sales_order_delivery').execute(
{ sales_order_id: ORDER_ID, lines: [{ sales_order_item_id: ITEM_ID, delivered_qty: 1 }], dry_run: true },
'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_INVALID_STATE/)
})
it('dry run: previews the cumulative delivery per line and skips text rows', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({
ok: true,
order: makeOrder({
items: [makeItem({ delivered_qty: 4 }), makeItem({ id: TEXT_ID, line_type: 'text', quantity: 0, sort_order: 1 })],
}),
})
const { supabase } = createQueuedMockSupabase()
const result = (await byName('gnubok_register_sales_order_delivery').execute(
{
sales_order_id: ORDER_ID,
delivery_date: '2026-09-03',
lines: [{ sales_order_item_id: ITEM_ID, delivered_qty: 7 }, { sales_order_item_id: TEXT_ID, delivered_qty: 0 }],
dry_run: true,
},
'company-1', 'user-1', supabase as never,
)) as { staged: boolean; preview: { lines: Record<string, unknown>[]; delivery_date: string } }
expect(result.staged).toBe(false)
expect(result.preview.delivery_date).toBe('2026-09-03')
expect(result.preview.lines).toHaveLength(1)
expect(result.preview.lines[0]).toMatchObject({ sales_order_item_id: ITEM_ID, delivered_before: 4, delivered_after: 7, delta: 3 })
})
})
describe('gnubok_create_invoice_from_sales_order', () => {
it('refuses a draft order with a confirm hint', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({ ok: true, order: makeOrder({ status: 'draft' }) })
await expect(
byName('gnubok_create_invoice_from_sales_order').execute(
{ sales_order_id: ORDER_ID, dry_run: true }, 'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_INVALID_STATE.*gnubok_transition_sales_order/)
})
it('refuses when nothing is left to invoice', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({
ok: true,
order: makeOrder({ items: [makeItem({ invoiced_qty: 10, remaining_qty: 0 })] }),
})
await expect(
byName('gnubok_create_invoice_from_sales_order').execute(
{ sales_order_id: ORDER_ID, dry_run: true }, 'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_NOTHING_TO_INVOICE/)
})
it('refuses an explicit pick above remaining_qty', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({
ok: true,
order: makeOrder({ items: [makeItem({ invoiced_qty: 8, remaining_qty: 2 })] }),
})
await expect(
byName('gnubok_create_invoice_from_sales_order').execute(
{ sales_order_id: ORDER_ID, lines: [{ sales_order_item_id: ITEM_ID, quantity: 3 }], dry_run: true },
'company-1', 'user-1', {} as never,
),
).rejects.toThrow(/SALES_ORDER_OVER_INVOICED/)
})
it('dry run: previews the delivered-not-invoiced quantities with line totals and due date', async () => {
vi.mocked(loadSalesOrder).mockResolvedValue({
ok: true,
order: makeOrder({ last_delivery_date: '2026-09-02', items: [makeItem({ delivered_qty: 4, invoiced_qty: 1, remaining_qty: 9 })] }),
})
const { supabase, findCalls } = createQueuedMockSupabase()
const result = (await byName('gnubok_create_invoice_from_sales_order').execute(
{ sales_order_id: ORDER_ID, mode: 'delivered', invoice_date: '2026-09-05', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.risk_level).toBe('medium')
// delivered 4 - invoiced 1 = 3 x 100 = 300 net, 75 VAT.
expect(result.preview).toMatchObject({
mode: 'delivered',
subtotal: 300,
vat_amount: 75,
total: 375,
invoice_date: '2026-09-05',
due_date: '2026-10-05',
delivery_date: '2026-09-02',
})
expect((result.preview.items as Record<string, unknown>[])[0]).toMatchObject({
sales_order_item_id: ITEM_ID,
quantity: 3,
line_total: 300,
remaining_after: 6,
})
expect(findCalls('pending_operations', 'insert')).toEqual([])
})
})
+839 -3
View File
@@ -220,7 +220,18 @@ import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
import {
CreateInvoiceFromSalesOrderSchema,
CreateSalesOrderSchema,
RegisterSalesOrderDeliverySchema,
RotRutBeslutFileSchema,
SalesOrderTransitionSchema,
} from '@/lib/api/schemas'
import { decorate as decorateSalesOrder, fetchInvoicedQuantities, loadSalesOrder } from '@/lib/sales-orders/load'
import { normalizeSalesOrderLines } from '@/lib/sales-orders/lines'
import { hasOpenInvoices } from '@/lib/sales-orders/write'
import { pickLines } from '@/lib/sales-orders/create-invoice-from-order'
import type { ServiceFailure } from '@/lib/sales-orders/result'
import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
@@ -289,7 +300,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append'
import { getUserCompanies } from '@/lib/company/context'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler: no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType, VatDeclarationRutor, YearEndBlockerCode } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType, VatDeclarationRutor, YearEndBlockerCode, SalesOrder, SalesOrderItem, SalesOrderStatus } from '@/types'
// ── Actor context ────────────────────────────────────────────
@@ -3047,6 +3058,165 @@ function projectMcpPayload<T>(value: T, namespace: McpToolNamespace): T {
return projectToolReferences(value, namespace, getCanonicalToolNames())
}
// ── Kundorder (sales orders) helpers ─────────────────────────
const SALES_ORDER_STATUSES = ['draft', 'confirmed', 'completed', 'cancelled'] as const
const SALES_ORDER_PROGRESS = ['none', 'partial', 'full'] as const
/**
* Staging-time mirror of the header state machine in
* lib/sales-orders/transitions.ts. The service is authoritative at commit
* (it re-reads the order and compare-and-sets on the status it saw); this
* copy exists only so an impossible transition is refused before it costs
* an approval round-trip.
*/
const SALES_ORDER_TRANSITIONS: Record<
'confirm' | 'cancel' | 'reopen',
{ from: readonly SalesOrderStatus[]; to: SalesOrderStatus }
> = {
confirm: { from: ['draft'], to: 'confirmed' },
cancel: { from: ['draft', 'confirmed'], to: 'cancelled' },
reopen: { from: ['cancelled'], to: 'draft' },
}
/** Surface a lib/sales-orders ServiceFailure through the tool error envelope. */
function throwSalesOrderFailure(failure: ServiceFailure, context: string): never {
if ('dbError' in failure) throw dbError(failure.dbError)
const entry = getErrorEntry(failure.code)
const details = failure.details ? ` ${JSON.stringify(failure.details)}` : ''
throw new Error(`${context}: ${failure.code}. ${entry?.message_en ?? ''}${details}`.trim())
}
async function loadSalesOrderOrThrow(
supabase: SupabaseClient,
companyId: string,
rawId: unknown,
): Promise<SalesOrder> {
const orderId = String(rawId ?? '').trim()
if (!orderId) throw new Error('sales_order_id is required. Use gnubok_list_sales_orders to find IDs.')
const res = await loadSalesOrder(supabase, companyId, orderId)
if (!res.ok) {
if ('code' in res && res.code === 'SALES_ORDER_NOT_FOUND') {
throw new Error('Sales order not found. Use gnubok_list_sales_orders to find valid IDs.')
}
throwSalesOrderFailure(res, 'Could not load sales order')
}
return res.order
}
/** First Zod issue of a staged sales-order payload as a tool error. */
function throwFirstZodIssue(result: { success: false; error: z.ZodError }, field?: string): never {
const issue = result.error.issues[0]
const path = [field, ...(issue?.path ?? [])].filter((p) => p !== undefined && p !== '').join('.')
throw new Error(`Invalid ${path || 'arguments'}: ${issue?.message ?? 'validation failed'}`)
}
function salesOrderSummary(order: SalesOrder) {
return {
sales_order_id: order.id,
order_number: order.order_number ?? null,
status: order.status,
customer_id: order.customer_id ?? null,
customer_name: (order.customer as { name?: string } | null | undefined)?.name ?? null,
order_date: order.order_date,
requested_delivery_date: order.requested_delivery_date ?? null,
last_delivery_date: order.last_delivery_date ?? null,
currency: order.currency,
subtotal: order.subtotal,
vat_amount: order.vat_amount,
total: order.total,
delivery_progress: order.delivery_progress ?? 'none',
invoicing_progress: order.invoicing_progress ?? 'none',
line_count: (order.items ?? []).length,
}
}
function salesOrderLineOut(item: SalesOrderItem) {
return {
sales_order_item_id: item.id,
line_type: item.line_type,
description: item.description,
quantity: item.quantity,
delivered_qty: item.delivered_qty,
invoiced_qty: item.invoiced_qty ?? 0,
remaining_qty: item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0)),
unit: item.unit,
unit_price: item.unit_price,
discount_percent: item.discount_percent,
line_total: item.line_total,
vat_rate: item.vat_rate,
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
dimensions: item.dimensions ?? {},
}
}
const SALES_ORDER_SUMMARY_PROPS = {
sales_order_id: { type: 'string' },
order_number: { type: ['string', 'null'], description: 'OR-<n>; null only if numbering failed at creation' },
status: { type: 'string', enum: SALES_ORDER_STATUSES },
customer_id: { type: ['string', 'null'] },
customer_name: { type: ['string', 'null'] },
order_date: { type: 'string' },
requested_delivery_date: { type: ['string', 'null'] },
last_delivery_date: { type: ['string', 'null'], description: 'Latest registered delivery; becomes delivery_date on invoices created from the order' },
currency: { type: 'string' },
subtotal: { type: 'number' },
vat_amount: { type: 'number' },
total: { type: 'number' },
delivery_progress: { type: 'string', enum: SALES_ORDER_PROGRESS },
invoicing_progress: { type: 'string', enum: SALES_ORDER_PROGRESS },
line_count: { type: 'number' },
} as const
const SALES_ORDER_SUMMARY_REQUIRED = [
'sales_order_id', 'status', 'order_date', 'currency', 'subtotal', 'vat_amount', 'total',
'delivery_progress', 'invoicing_progress', 'line_count',
] as const
const SALES_ORDER_LINE_OUTPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
sales_order_item_id: { type: 'string' },
line_type: { type: 'string', description: 'product or text' },
description: { type: 'string' },
quantity: { type: 'number' },
delivered_qty: { type: 'number', description: 'Cumulative delivered quantity registered so far' },
invoiced_qty: { type: 'number', description: 'Derived from linked lines on non-cancelled, non-credited invoices' },
remaining_qty: { type: 'number', description: 'quantity - invoiced_qty, never negative' },
unit: { type: 'string' },
unit_price: { type: 'number' },
discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' },
line_total: { type: 'number', description: 'Net of discount, order currency' },
vat_rate: { type: 'number' },
article_id: { type: ['string', 'null'] },
revenue_account: { type: ['string', 'null'] },
dimensions: { type: 'object', additionalProperties: { type: 'string' } },
},
required: [
'sales_order_item_id', 'line_type', 'description', 'quantity', 'delivered_qty', 'invoiced_qty',
'remaining_qty', 'unit', 'unit_price', 'line_total', 'vat_rate',
],
} as const
const SALES_ORDER_LINE_INPUT_SCHEMA = {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Per unit excl. VAT' },
discount_percent: { type: 'number', description: 'Line discount 0-100' },
vat_rate: { type: 'number', description: 'VAT rate 0-100; default from customer VAT rules' },
article_id: { type: 'string', description: 'Article UUID; prefills like gnubok_create_invoice, line values win' },
line_type: { type: 'string', enum: ['product', 'text'], description: 'text = free-text row, no amounts' },
revenue_account: { type: ['string', 'null'], description: 'BAS class 1-3 account override' },
dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}' },
},
required: ['quantity'],
} as const
// ── Tools ────────────────────────────────────────────────────
export const tools: McpTool[] = [
@@ -6424,7 +6594,7 @@ export const tools: McpTool[] = [
const { data: invoice, error } = await supabase
.from('invoices')
.select(
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, sales_order_item_id, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -6755,6 +6925,672 @@ export const tools: McpTool[] = [
},
},
// ── Kundorder (sales orders) ─────────────────────────────────
//
// The non-ledger document between agreement and invoice. Orders never
// book: delivery is a fact the user records, invoicing creates a DRAFT
// kundfaktura through the same builder gnubok_create_invoice uses. Every
// write stages for approval and commits through lib/sales-orders.
{
name: 'gnubok_list_sales_orders',
keywords: ['kundorder', 'order', 'ordrar', 'orderbekräftelse', 'leverans', 'delfaktura'],
title: 'List Sales Orders',
description: 'List kundorder (sales orders) for the active company, newest first, with delivery and invoicing progress per order. Optional status and customer filters. Use gnubok_get_sales_order for the lines.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: { type: 'string', enum: SALES_ORDER_STATUSES, description: 'Filter by order status' },
customer_id: { type: 'string', description: 'Filter by customer UUID' },
limit: { type: 'number', description: 'Max results (default 50, max 100)' },
offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' },
},
},
// Item keys listed in prose instead of declared: the typed summary schema
// costs ~350 tokens of the tools/list budget (payload-size.bench.test.ts)
// and gnubok_get_sales_order (search-only) declares the same fields fully.
outputSchema: paginatedSchema('sales_orders', {
type: 'object',
description: 'sales_order_id, order_number, status, customer_id, customer_name, order_date, requested_delivery_date, last_delivery_date, currency, subtotal, vat_amount, total, delivery_progress, invoicing_progress (none|partial|full), line_count',
}),
annotations: ANNOTATIONS_READ_ONLY,
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const offset = Math.max(0, Math.floor(Number(args.offset) || 0))
const status = typeof args.status === 'string' ? args.status : undefined
const customerId = typeof args.customer_id === 'string' ? args.customer_id.trim() : ''
if (status && !(SALES_ORDER_STATUSES as readonly string[]).includes(status)) {
throw new Error(`Invalid status "${status}". Allowed: ${SALES_ORDER_STATUSES.join(', ')}`)
}
let query = supabase
.from('sales_orders')
.select(
'id, order_number, status, customer_id, order_date, requested_delivery_date, last_delivery_date, currency, subtotal, vat_amount, total, customer:customers(name), items:sales_order_items(id, line_type, quantity, delivered_qty, sort_order)',
{ count: 'exact' },
)
.eq('company_id', companyId)
if (status) query = query.eq('status', status)
if (customerId) query = query.eq('customer_id', customerId)
const { data, error, count } = await query
.order('order_date', { ascending: false })
.order('id', { ascending: false })
.range(offset, offset + limit)
if (error) throw dbError(error)
const rows = ((data ?? []) as unknown as SalesOrder[]).slice(0, limit)
// Progress needs the invoiced quantity per line, which lives on the
// linked invoice_items (one RPC for the whole page, RLS applies).
const invoiced = await fetchInvoicedQuantities(supabase, rows.map((r) => r.id))
if (!invoiced.ok) throw dbError(invoiced.dbError)
const salesOrders = rows.map((row) => salesOrderSummary(decorateSalesOrder(row, invoiced.byItem)))
const fetched = (data ?? []).length
const hasMore = count == null ? fetched > limit : offset + salesOrders.length < count
const total = count ?? offset + salesOrders.length + (hasMore ? 1 : 0)
return {
sales_orders: salesOrders,
count: salesOrders.length,
total_count: total,
has_more: hasMore,
...(hasMore ? { next_offset: offset + salesOrders.length } : {}),
}
},
},
{
name: 'gnubok_get_sales_order',
keywords: ['kundorder', 'order', 'orderrader', 'leverans', 'delfaktura'],
title: 'Get Sales Order',
description: 'One kundorder (sales order): header plus every line with delivered_qty, invoiced_qty and remaining_qty, and the invoices created from it. Read it before registering delivery or invoicing so line ids and remaining quantities are exact.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' },
},
required: ['sales_order_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
...SALES_ORDER_SUMMARY_PROPS,
source_invoice_id: { type: ['string', 'null'], description: 'Proforma the order was converted from, if any' },
your_reference: { type: ['string', 'null'] },
our_reference: { type: ['string', 'null'] },
notes: { type: ['string', 'null'] },
default_dimensions: { type: 'object', additionalProperties: { type: 'string' } },
confirmed_at: { type: ['string', 'null'] },
completed_at: { type: ['string', 'null'] },
cancelled_at: { type: ['string', 'null'] },
items: { type: 'array', description: 'Lines in display order', items: SALES_ORDER_LINE_OUTPUT_SCHEMA },
item_count: { type: 'number' },
invoices: {
type: 'array',
description: 'Invoices created from this order (all statuses)',
items: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string' },
invoice_number: { type: ['string', 'null'], description: 'null until sent' },
status: { type: 'string' },
invoice_date: { type: 'string' },
total: { type: 'number' },
currency: { type: 'string' },
},
required: ['invoice_id', 'status', 'total', 'currency'],
},
},
},
required: [...SALES_ORDER_SUMMARY_REQUIRED, 'items', 'item_count', 'invoices'],
},
annotations: ANNOTATIONS_READ_ONLY,
// Search-only like gnubok_get_invoice: the line-level detail read behind
// the delivery and invoicing writes. Reads stay callable on Claude.ai via
// gnubok_call_tool; the tools/list budget (payload-size.bench.test.ts)
// has no room for its typed line schema in the default catalog.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase) {
const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id)
const { data: invoiceRows, error: invoiceError } = await supabase
.from('invoices')
.select('id, invoice_number, status, invoice_date, total, currency')
.eq('company_id', companyId)
.eq('sales_order_id', order.id)
.order('invoice_date', { ascending: true })
.order('id', { ascending: true })
if (invoiceError) throw dbError(invoiceError)
const items = (order.items ?? []).map(salesOrderLineOut)
return {
...salesOrderSummary(order),
source_invoice_id: order.source_invoice_id ?? null,
your_reference: order.your_reference ?? null,
our_reference: order.our_reference ?? null,
notes: order.notes ?? null,
default_dimensions: order.default_dimensions ?? {},
confirmed_at: order.confirmed_at ?? null,
completed_at: order.completed_at ?? null,
cancelled_at: order.cancelled_at ?? null,
items,
item_count: items.length,
invoices: (invoiceRows ?? []).map((inv) => ({
invoice_id: inv.id,
invoice_number: inv.invoice_number ?? null,
status: inv.status,
invoice_date: inv.invoice_date,
total: inv.total,
currency: inv.currency,
})),
}
},
},
{
name: 'gnubok_create_sales_order',
keywords: ['kundorder', 'order', 'ny order', 'orderbekräftelse'],
title: 'Create Sales Order',
description: 'Stage a new kundorder (sales order) as a draft with its lines. Stages for approval: nothing is booked; totals are informational. Confirm it afterwards with gnubok_transition_sales_order, then deliver and invoice from it.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
customer_id: { type: 'string', description: 'Customer UUID' },
items: { type: 'array', items: SALES_ORDER_LINE_INPUT_SCHEMA, description: 'Order lines' },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn} applied to every item not setting the key',
},
order_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
requested_delivery_date: { type: 'string', description: 'YYYY-MM-DD' },
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
our_reference: { type: 'string' },
your_reference: { type: 'string' },
notes: { type: 'string' },
dry_run: { type: 'boolean', description: 'Preview without staging' },
idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' },
},
required: ['customer_id', 'items'],
},
annotations: ANNOTATIONS_IDEMPOTENT_WRITE,
// Search-only (same footing as the mileage and skattekonto families): the
// tools/list budget (payload-size.bench.test.ts) has ~900 tokens of
// headroom and the four staged kundorder writes need ~2000 even trimmed.
// gnubok_list_sales_orders stays in the default catalog as the entry
// point; promoting the writes means demoting other reads first.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const customerId = typeof args.customer_id === 'string' ? args.customer_id.trim() : ''
const rawItems = Array.isArray(args.items) ? (args.items as StagedInvoiceLineInput[]) : []
if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.')
if (rawItems.length === 0) throw new Error('At least one item is required.')
const { data: customer, error: custError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', companyId)
.maybeSingle<Customer>()
if (custError) throw dbError(custError)
if (!customer) throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.')
const currency = ((args.currency as string) || 'SEK') as Currency
const today = new Date().toISOString().split('T')[0]
// Article prefill with the same rules as gnubok_create_invoice: the
// line's own values win, the article fills the rest, and its VAT rate
// is adopted only inside the customer's default rate set.
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a)))
const articlesById = new Map<string, InvoiceLineArticle>()
if (articleIds.length > 0) {
const { data: articleRows, error: articleError } = await supabase
.from('articles')
.select('id, name, unit, price_excl_vat, vat_rate, revenue_account, currency, active')
.eq('company_id', companyId)
.in('id', articleIds)
if (articleError) throw dbError(articleError)
for (const row of articleRows ?? []) articlesById.set(row.id, row)
}
const resolvedLines = rawItems.map((item, i) =>
resolveInvoiceLineFromArticle(
item,
item.article_id ? articlesById.get(item.article_id) : undefined,
currency,
adoptableVatRates,
i,
),
)
// Resolve-don't-select for dimensions (names to registry codes), same
// as gnubok_create_invoice; the resolved bags are what gets staged.
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[defaultDimensions, ...resolvedLines.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))],
)
const resolvedDefaultDimensions = resolvedDimBags[0]
const items = resolvedLines.map((line, i) => {
const bag = resolvedDimBags[i + 1]
return {
line_type: line.line_type ?? 'product',
description: line.description,
quantity: line.quantity,
unit: line.unit,
unit_price: line.unit_price,
...(line.discount_percent != null ? { discount_percent: line.discount_percent } : {}),
...(line.vat_rate != null ? { vat_rate: line.vat_rate } : {}),
...(line.article_id ? { article_id: line.article_id } : {}),
...(line.revenue_account != null ? { revenue_account: line.revenue_account } : {}),
...(bag && Object.keys(bag).length > 0 ? { dimensions: bag } : {}),
}
})
// Same schema the cookie route validates with, so a payload refused
// here is refused identically at the commit boundary.
const parsed = CreateSalesOrderSchema.safeParse({
customer_id: customerId,
order_date: (args.order_date as string) || today,
...(args.requested_delivery_date ? { requested_delivery_date: args.requested_delivery_date } : {}),
currency,
...(args.your_reference ? { your_reference: args.your_reference } : {}),
...(args.our_reference ? { our_reference: args.our_reference } : {}),
...(args.notes ? { notes: args.notes } : {}),
...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0
? { default_dimensions: resolvedDefaultDimensions }
: {}),
items,
})
if (!parsed.success) throwFirstZodIssue(parsed)
const params = parsed.data
// Preview totals from the exact line math the service stores (net of
// discount, öre-exact) including the per-customer VAT gate.
const lines = normalizeSalesOrderLines(params.items, customer)
if (!lines.ok) throwSalesOrderFailure(lines, 'Cannot create sales order')
return stagePendingOperation(supabase, companyId, userId, 'create_sales_order',
`Ny kundorder: ${customer.name} ${lines.totals.total} ${currency}`,
params as unknown as Record<string, unknown>,
{
customer_name: customer.name,
customer_type: customer.customer_type,
items: lines.rows.map((row) => ({
line_type: row.line_type,
description: row.description,
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
discount_percent: row.discount_percent,
vat_rate: row.vat_rate,
line_total: row.line_total,
article_id: row.article_id,
revenue_account: row.revenue_account,
dimensions: row.dimensions,
})),
subtotal: lines.totals.subtotal,
vat_amount: lines.totals.vat_amount,
total: lines.totals.total,
currency,
order_date: params.order_date,
requested_delivery_date: params.requested_delivery_date ?? null,
// Informational: an order is not a verifikat, so no period check.
writes_verifikat: false,
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
description: 'Once approved, the order is a draft. Confirm it with gnubok_transition_sales_order (action: confirm) before delivering or invoicing.',
tool: 'gnubok_transition_sales_order',
args: { action: 'confirm' },
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
{
name: 'gnubok_transition_sales_order',
keywords: ['kundorder', 'order', 'bekräfta order', 'makulera order', 'återöppna order'],
title: 'Transition Sales Order',
description: 'Stage a status change on a kundorder: confirm (draft to confirmed), cancel (draft or confirmed), or reopen (cancelled to draft). Stages for approval. Cancel and reopen are refused while invoices created from the order exist.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' },
action: { type: 'string', enum: ['confirm', 'cancel', 'reopen'] },
dry_run: { type: 'boolean', description: 'Preview without staging' },
idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' },
},
required: ['sales_order_id', 'action'],
},
annotations: ANNOTATIONS_IDEMPOTENT_WRITE,
// Search-only: see gnubok_create_sales_order.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const parsedAction = SalesOrderTransitionSchema.safeParse({ action: args.action })
if (!parsedAction.success) throwFirstZodIssue(parsedAction)
const { action } = parsedAction.data
const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id)
const rule = SALES_ORDER_TRANSITIONS[action]
if (!rule.from.includes(order.status)) {
throw new Error(
`Cannot ${action} sales order ${order.order_number ?? order.id}: SALES_ORDER_INVALID_STATE. ` +
`Status is "${order.status}"; ${action} requires ${rule.from.map((s) => `"${s}"`).join(' or ')}.`,
)
}
if (action === 'confirm' && !order.customer_id) {
throw new Error('Cannot confirm sales order: SALES_ORDER_CUSTOMER_MISSING. Set a customer first.')
}
if (action === 'cancel' || action === 'reopen') {
const open = await hasOpenInvoices(supabase, companyId, order.id)
if (!open.ok) throw dbError(open.dbError)
if (open.open) {
throw new Error(
`Cannot ${action} sales order ${order.order_number ?? order.id}: SALES_ORDER_HAS_INVOICES. ` +
'Cancel or credit the invoices created from it first (see gnubok_get_sales_order.invoices).',
)
}
}
const label = order.order_number ?? order.id
const titleVerb = action === 'confirm' ? 'Bekräfta' : action === 'cancel' ? 'Makulera' : 'Återöppna'
const customerName = (order.customer as { name?: string } | null | undefined)?.name ?? null
return stagePendingOperation(supabase, companyId, userId, 'transition_sales_order',
`${titleVerb} kundorder ${label}`,
{ sales_order_id: order.id, action },
{
sales_order_id: order.id,
order_number: order.order_number ?? null,
customer_name: customerName,
action,
current_status: order.status,
new_status: rule.to,
total: order.total,
currency: order.currency,
writes_verifikat: false,
},
actor,
action === 'confirm'
? {
description: 'Once confirmed, register deliveries with gnubok_register_sales_order_delivery or invoice it with gnubok_create_invoice_from_sales_order.',
tool: 'gnubok_create_invoice_from_sales_order',
args: { sales_order_id: order.id },
}
: {
description: 'Check the order afterwards with gnubok_get_sales_order.',
tool: 'gnubok_get_sales_order',
args: { sales_order_id: order.id },
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
{
name: 'gnubok_register_sales_order_delivery',
keywords: ['kundorder', 'leverans', 'leverera', 'delleverans', 'levererat antal'],
title: 'Register Sales Order Delivery',
description: 'Stage delivered quantities on a confirmed kundorder. delivered_qty is CUMULATIVE per line (the new total, not a delta), so retries are safe. Stages for approval; nothing is booked. Sets the delivery date used by invoices created afterwards.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' },
delivery_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
lines: {
type: 'array',
description: 'Delivered lines; omitted lines are untouched',
items: {
type: 'object',
properties: {
sales_order_item_id: { type: 'string', description: 'Line UUID from gnubok_get_sales_order' },
delivered_qty: { type: 'number', description: 'Cumulative delivered total, 0..quantity' },
},
required: ['sales_order_item_id', 'delivered_qty'],
},
},
dry_run: { type: 'boolean', description: 'Preview without staging' },
idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' },
},
required: ['sales_order_id', 'lines'],
},
annotations: ANNOTATIONS_IDEMPOTENT_WRITE,
// Search-only: see gnubok_create_sales_order.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const parsed = RegisterSalesOrderDeliverySchema.safeParse({
...(args.delivery_date ? { delivery_date: args.delivery_date } : {}),
lines: args.lines,
})
if (!parsed.success) throwFirstZodIssue(parsed)
const input = parsed.data
const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id)
if (order.status !== 'confirmed' && order.status !== 'completed') {
throw new Error(
`Cannot register delivery on sales order ${order.order_number ?? order.id}: SALES_ORDER_INVALID_STATE. ` +
`Status is "${order.status}"; confirm it first with gnubok_transition_sales_order.`,
)
}
// Same per-line guards the service applies at commit (line exists,
// text rows carry no quantity, never above the ordered quantity), so
// the agent learns about a bad line now instead of after approval.
const byId = new Map((order.items ?? []).map((i) => [i.id, i]))
const previewLines: Record<string, unknown>[] = []
for (const line of input.lines) {
const item = byId.get(line.sales_order_item_id)
if (!item) {
throw new Error(
`SALES_ORDER_LINE_NOT_FOUND: line ${line.sales_order_item_id} is not on this order. Read gnubok_get_sales_order for the line ids.`,
)
}
if (item.line_type === 'text') continue
if (line.delivered_qty > item.quantity) {
throw new Error(
`SALES_ORDER_OVER_DELIVERED: line "${item.description}" has quantity ${item.quantity}, cannot mark ${line.delivered_qty} delivered.`,
)
}
previewLines.push({
sales_order_item_id: item.id,
description: item.description,
quantity: item.quantity,
unit: item.unit,
delivered_before: item.delivered_qty,
delivered_after: line.delivered_qty,
delta: roundOre(line.delivered_qty - item.delivered_qty),
})
}
if (previewLines.length === 0) throw new Error('No product lines to deliver: every referenced line is a text row.')
const deliveryDate = input.delivery_date ?? new Date().toISOString().split('T')[0]
const label = order.order_number ?? order.id
const customerName = (order.customer as { name?: string } | null | undefined)?.name ?? null
return stagePendingOperation(supabase, companyId, userId, 'register_sales_order_delivery',
`Leverans kundorder ${label}`,
{ sales_order_id: order.id, delivery_date: deliveryDate, lines: input.lines },
{
sales_order_id: order.id,
order_number: order.order_number ?? null,
customer_name: customerName,
delivery_date: deliveryDate,
lines: previewLines,
// Quantities only: no inventory, no verifikat.
writes_verifikat: false,
},
actor,
{
description: 'Once approved, invoice the delivered quantities with gnubok_create_invoice_from_sales_order (mode: delivered).',
tool: 'gnubok_create_invoice_from_sales_order',
args: { sales_order_id: order.id, mode: 'delivered' },
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
{
name: 'gnubok_create_invoice_from_sales_order',
keywords: ['kundorder', 'fakturera order', 'delfaktura', 'delfakturera', 'slutfaktura', 'fakturera leverans'],
title: 'Create Invoice From Sales Order',
description: 'Stage a DRAFT kundfaktura from a confirmed kundorder: mode remaining (default) bills everything left, delivered bills what is delivered but not yet invoiced, or pick lines explicitly (delfaktura). Stages for approval; the number is assigned on send.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' },
mode: { type: 'string', enum: ['remaining', 'delivered'], description: 'Line selection when lines is omitted (default remaining)' },
lines: {
type: 'array',
description: 'Explicit picks (win over mode)',
items: {
type: 'object',
properties: {
sales_order_item_id: { type: 'string', description: 'Line UUID from gnubok_get_sales_order' },
quantity: { type: 'number', description: 'Positive, at most remaining_qty' },
},
required: ['sales_order_item_id', 'quantity'],
},
},
invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' },
dry_run: { type: 'boolean', description: 'Preview without staging' },
idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' },
},
required: ['sales_order_id'],
},
annotations: ANNOTATIONS_IDEMPOTENT_WRITE,
// Search-only: see gnubok_create_sales_order.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const parsed = CreateInvoiceFromSalesOrderSchema.safeParse({
...(args.mode ? { mode: args.mode } : {}),
...(args.lines !== undefined ? { lines: args.lines } : {}),
...(args.invoice_date ? { invoice_date: args.invoice_date } : {}),
...(args.due_date ? { due_date: args.due_date } : {}),
})
if (!parsed.success) throwFirstZodIssue(parsed)
const input = parsed.data
const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id)
const label = order.order_number ?? order.id
if (order.status !== 'confirmed') {
const hint =
order.status === 'draft'
? 'Confirm it first with gnubok_transition_sales_order (action: confirm).'
: order.status === 'completed'
? 'It is fully invoiced already; see gnubok_get_sales_order.invoices.'
: 'A cancelled order cannot be invoiced; reopen and confirm it first.'
throw new Error(`Cannot invoice sales order ${label}: SALES_ORDER_INVALID_STATE. Status is "${order.status}". ${hint}`)
}
if (!order.customer_id) {
throw new Error(`Cannot invoice sales order ${label}: SALES_ORDER_CUSTOMER_MISSING. Set a customer first.`)
}
// The same picker the service runs at commit, against the CURRENT
// invoiced quantities: nothing left to bill, or a pick above
// remaining_qty, is refused here rather than after approval.
const pickedRes = pickLines(order, input)
if (!pickedRes.ok) throwSalesOrderFailure(pickedRes, `Cannot invoice sales order ${label}`)
const { picked } = pickedRes
// Preview line math: computeLineNet + roundOre, the same primitives the
// invoice builder uses; the builder is authoritative at commit.
let subtotal = 0
let vatAmount = 0
const previewLines = picked.map(({ item, quantity }) => {
const remaining = item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0))
const lineTotal = computeLineNet(quantity, item.unit_price, item.discount_percent)
const lineVat = roundOre((lineTotal * item.vat_rate) / 100)
subtotal = roundOre(subtotal + lineTotal)
vatAmount = roundOre(vatAmount + lineVat)
return {
sales_order_item_id: item.id,
description: item.description,
quantity,
unit: item.unit,
unit_price: item.unit_price,
discount_percent: item.discount_percent,
vat_rate: item.vat_rate,
line_total: lineTotal,
vat_amount: lineVat,
remaining_after: roundOre(remaining - quantity),
}
})
const total = roundOre(subtotal + vatAmount)
const customer = order.customer as (Customer | null | undefined)
const invoiceDate = input.invoice_date ?? new Date().toISOString().split('T')[0]
let dueDate = input.due_date
if (!dueDate) {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + (customer?.default_payment_terms ?? 30))
dueDate = due.toISOString().split('T')[0]
}
const anyDelivered = picked.some((p) => p.item.delivered_qty > 0)
return stagePendingOperation(supabase, companyId, userId, 'create_invoice_from_sales_order',
`Faktura från kundorder ${label}: ${customer?.name ?? ''} ${total} ${order.currency}`.replace(/\s+/g, ' '),
{
sales_order_id: order.id,
...(input.mode ? { mode: input.mode } : {}),
...(input.lines ? { lines: input.lines } : {}),
invoice_date: invoiceDate,
due_date: dueDate,
},
{
sales_order_id: order.id,
order_number: order.order_number ?? null,
customer_name: customer?.name ?? null,
mode: input.lines && input.lines.length > 0 ? 'explicit' : (input.mode ?? 'remaining'),
items: previewLines,
subtotal,
vat_amount: vatAmount,
total,
currency: order.currency,
invoice_date: invoiceDate,
due_date: dueDate,
// Taxable-event date only when the invoice covers delivered goods.
delivery_date: anyDelivered ? (order.last_delivery_date ?? null) : null,
creates_draft: true,
},
actor,
{
description: 'Once approved, the invoice exists as a draft linked to the order. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.',
tool: 'gnubok_send_invoice',
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
// ── Report tools ─────────────────────────────────────────────
{