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
@@ -175,6 +175,29 @@ describe('PATCH /api/invoices/[id]', () => {
expect(emitSpy).not.toHaveBeenCalled()
})
it('returns 409 INVOICE_UPDATE_DROPS_ORDER_LINK when the new lines drop a kundorder link', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, journal_entry_id: null, is_self_billed: false },
error: null,
}) // existing
enqueue({ data: makeCustomer({ id: 'customer-1', customer_type: 'swedish_business' }), error: null }) // customer
enqueue({ data: { vat_registered: true }, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // header update matched
enqueue({
// snapshot: the stored line is linked to an order line; VALID_BODY carries no sales_order_item_id
data: [{ id: 'item-old-1', invoice_id: 'inv-1', sales_order_item_id: 'd1000000-0000-4000-8000-000000000001' }],
error: null,
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch('inv-1'))
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_UPDATE_DROPS_ORDER_LINK')
// The guard fires before the delete: from() was called for existing,
// customer, settings, update and snapshot only, never for delete/insert.
expect(mockSupabase.from).toHaveBeenCalledTimes(5)
})
it('returns 409 when the draft is sent/finalized concurrently (0-row update)', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, journal_entry_id: null, is_self_billed: false },
@@ -0,0 +1,330 @@
/**
* POST /api/invoices/[id]/convert-to-order: proforma -> draft kundorder.
*
* Queue order: invoices select (proforma + items), sales_orders head count
* (already converted?), then createSalesOrder (customers select,
* sales_orders insert, sales_order_items insert, generate number rpc,
* sales_orders select, invoiced rpc), then the invoices compare-and-set
* update that marks the proforma cancelled.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
makeInvoice,
} from '@/tests/helpers'
import { IDS, makeOrderCustomer, makeSalesOrder } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const params = createMockRouteParams({ id: IDS.invoice })
function post() {
return POST(createMockRequest(`/api/invoices/${IDS.invoice}/convert-to-order`, { method: 'POST' }), params)
}
function makeProforma(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({
id: IDS.invoice,
customer_id: IDS.customer,
document_type: 'proforma',
status: 'sent',
invoice_number: 'P-2026001',
your_reference: 'Anna',
notes: 'Enligt offert',
}),
items: [
{
id: 'e2000000-0000-4000-8000-000000000002',
sort_order: 1,
line_type: 'text',
description: 'Tack för förtroendet',
quantity: 0,
unit: null,
unit_price: 0,
vat_rate: 0,
},
{
id: 'e2000000-0000-4000-8000-000000000001',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
unit_price: 100,
discount_percent: null,
vat_rate: 25,
article_id: null,
revenue_account: '3011',
dimensions: { project: 'P1' },
},
] as Record<string, unknown>[],
...overrides,
}
}
describe('POST /api/invoices/[id]/convert-to-order', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(403)
})
it('returns 404 when the invoice is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
})
it('returns 400 SALES_ORDER_SOURCE_NOT_PROFORMA for a real invoice', async () => {
enqueue({ data: makeProforma({ document_type: 'invoice' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_NOT_PROFORMA')
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('returns 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED for a cancelled proforma', async () => {
enqueue({ data: makeProforma({ status: 'cancelled' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
})
it('returns 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED when an order already points at the proforma', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 1 })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['source_invoice_id', IDS.invoice])
})
it('returns 409 SALES_ORDER_CUSTOMER_MISSING for a proforma without customer', async () => {
enqueue({ data: makeProforma({ customer_id: null }) })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_CUSTOMER_MISSING')
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a ROT line (order lines carry no skattereduktion)', async () => {
const proforma = makeProforma()
proforma.items[1] = {
...proforma.items[1],
deduction_type: 'rot',
labor_hours: 8,
work_type: 'bygg',
}
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines).toEqual([
{
invoice_item_id: 'e2000000-0000-4000-8000-000000000001',
deduction_type: 'rot',
accrual: false,
quantity: 10,
},
])
// Refused after the already-converted count, before the customer load and any insert.
expect(findCalls('sales_orders', 'eq')).toContainEqual(['source_invoice_id', IDS.invoice])
expect(findCall('customers', 'select')).toBeUndefined()
expect(findCall('sales_orders', 'insert')).toBeUndefined()
expect(findCall('invoices', 'update')).toBeUndefined()
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a negative-quantity line', async () => {
const proforma = makeProforma()
proforma.items.push({
...proforma.items[1],
id: 'e2000000-0000-4000-8000-000000000003',
sort_order: 2,
description: 'Rabatt',
quantity: -1,
unit_price: 200,
})
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines).toEqual([
{
invoice_item_id: 'e2000000-0000-4000-8000-000000000003',
deduction_type: null,
accrual: false,
quantity: -1,
},
])
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a periodised line', async () => {
const proforma = makeProforma()
proforma.items[1] = {
...proforma.items[1],
accrual_period_start: '2026-09-01',
accrual_period_end: '2027-08-31',
accrual_balance_account: '2990',
}
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines[0]).toMatchObject({ deduction_type: null, accrual: true })
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('does not treat a text row as unsupported (text rows have no deduction or quantity)', async () => {
// Same happy path as below, with the text row carrying a stray negative
// quantity: text rows are copied as quantity 0 and never gate the convert.
const proforma = makeProforma()
proforma.items[0] = { ...proforma.items[0], quantity: -1 }
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null })
enqueue({ data: 'OR-1' })
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice, order_number: 'OR-1' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.invoice }] })
const { status } = await parseJsonResponse(await post())
expect(status).toBe(201)
const lines = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(lines[1]).toMatchObject({ line_type: 'text', quantity: 0 })
})
it('creates a draft order from the proforma, cancels the proforma and answers 201 with sales_order_id', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } }) // sales_orders insert
enqueue({ data: null }) // sales_order_items insert
enqueue({ data: 'OR-1' }) // generate_sales_order_number
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice, order_number: 'OR-1' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.invoice }] }) // proforma CAS update
const { status, body } = await parseJsonResponse<{ data: SalesOrder; sales_order_id: string }>(await post())
expect(status).toBe(201)
expect(body.sales_order_id).toBe(IDS.order)
expect(body.data.id).toBe(IDS.order)
expect(body.data.source_invoice_id).toBe(IDS.invoice)
expect(body.data.status).toBe('draft')
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
customer_id: IDS.customer,
source_invoice_id: IDS.invoice,
currency: 'SEK',
your_reference: 'Anna',
notes: 'Enligt offert',
subtotal: 1000,
vat_amount: 250,
total: 1250,
})
// Lines copied in proforma sort order: product first, text row second.
const lines = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
revenue_account: '3011',
dimensions: { project: 'P1' },
line_total: 1000,
})
expect(lines[1]).toMatchObject({ sort_order: 1, line_type: 'text', quantity: 0, line_total: 0 })
expect(findCall('invoices', 'update')![0]).toEqual({ status: 'cancelled' })
expect(findCall('invoices', 'neq')).toEqual(['status', 'cancelled'])
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('removes the fresh order and answers 409 when the proforma was converted concurrently', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null })
enqueue({ data: 'OR-1' })
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice }) })
enqueue({ data: [] })
enqueue({ data: [] }) // CAS update matched nothing
enqueue({ data: null }) // order delete
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
expect(findCall('sales_orders', 'delete')).toBeDefined()
expect(findCalls('sales_orders', 'eq')).toContainEqual(['id', IDS.order])
})
})
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { convertProformaToSalesOrder } from '@/lib/sales-orders/convert-proforma'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/**
* POST /api/invoices/[id]/convert-to-order: proforma -> draft kundorder.
* Sibling of /convert (proforma -> invoice): copies the lines into a new
* order and marks the proforma cancelled.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.convert_to_order',
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const result = await convertProformaToSalesOrder(supabase, { companyId, userId: user.id, invoiceId: id })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order, sales_order_id: result.order.id }, { status: 201 })
},
{ requireWrite: true },
)
+3
View File
@@ -189,6 +189,9 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
// nothing else.
const replaced = await replaceInvoiceItems(supabase, id, build.items)
if (!replaced.ok) {
if (replaced.stage === 'guard') {
return errorResponseFromCode(replaced.code, ctxLog, { requestId })
}
ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
+5
View File
@@ -35,6 +35,11 @@ export const GET = withRouteContext(
if (status) {
query = query.eq('status', status)
}
// Kundorder detail: the invoices created from one order.
const salesOrderId = searchParams.get('sales_order_id')
if (salesOrderId && /^[0-9a-f-]{36}$/i.test(salesOrderId)) {
query = query.eq('sales_order_id', salesOrderId)
}
const { data, error, count } = await query
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceFromSalesOrderSchema } from '@/lib/api/schemas'
import { createInvoiceFromSalesOrder } from '@/lib/sales-orders/create-invoice-from-order'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/**
* POST /api/sales-orders/[id]/create-invoice: create an unnumbered DRAFT
* kundfaktura for the picked (or remaining / delivered) order lines. The
* user reviews and sends it through the normal invoice flow, which books
* it. Partial invoicing is the point: call again for the rest.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.create_invoice',
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CreateInvoiceFromSalesOrderSchema, {
log,
operation: 'sales_order.create_invoice',
})
if (!validation.success) return validation.response
const result = await createInvoiceFromSalesOrder(supabase, {
companyId,
userId: user.id,
orderId: id,
input: validation.data,
})
if (!result.ok) return serviceFailureResponse(result, log, requestId)
try {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: result.invoice, userId: user.id, companyId },
})
} catch {
// Non-critical
}
return NextResponse.json(
{ data: { invoice: result.invoice, order: result.order }, invoice_id: result.invoice.id },
{ status: 201 },
)
},
{ requireWrite: true },
)
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RegisterSalesOrderDeliverySchema } from '@/lib/api/schemas'
import { registerSalesOrderDelivery } from '@/lib/sales-orders/register-delivery'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/** POST /api/sales-orders/[id]/deliver: register cumulative delivered quantities. */
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.deliver',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, RegisterSalesOrderDeliverySchema, { log, operation: 'sales_order.deliver' })
if (!validation.success) return validation.response
const result = await registerSalesOrderDelivery(supabase, { companyId, orderId: id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSalesOrderSchema } from '@/lib/api/schemas'
import { loadSalesOrder } from '@/lib/sales-orders/load'
import { hasOpenInvoices, updateSalesOrder } from '@/lib/sales-orders/write'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
import { codeFromPgError } from '@/lib/sales-orders/result'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
type Ctx = { params: Promise<{ id: string }> }
/** GET /api/sales-orders/[id]: one order with lines + derived quantities. */
export const GET = withRouteContext<Ctx>(
'sales_order.get',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const result = await loadSalesOrder(supabase, companyId, id)
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
)
/** PATCH /api/sales-orders/[id]: edit header and/or replace lines (draft or confirmed). */
export const PATCH = withRouteContext<Ctx>(
'sales_order.update',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, UpdateSalesOrderSchema, { log, operation: 'sales_order.update' })
if (!validation.success) return validation.response
const result = await updateSalesOrder(supabase, { companyId, orderId: id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
/**
* DELETE /api/sales-orders/[id]: hard delete a draft or cancelled order
* that no invoice was created from. Orders never book and carry no
* sequence obligation, so nothing is lost; the RESTRICT FKs make a linked
* order undeletable at the DB level regardless.
*/
export const DELETE = withRouteContext<Ctx>(
'sales_order.delete',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const current = await loadSalesOrder(supabase, companyId, id)
if (!current.ok) return serviceFailureResponse(current, log, requestId)
if (current.order.status !== 'draft' && current.order.status !== 'cancelled') {
return errorResponseFromCode('SALES_ORDER_INVALID_STATE', log, {
requestId,
details: { status: current.order.status, action: 'delete' },
})
}
const open = await hasOpenInvoices(supabase, companyId, id)
if (!open.ok) return errorResponse(open.dbError, log, { requestId })
if (open.open) return errorResponseFromCode('SALES_ORDER_HAS_INVOICES', log, { requestId })
// Status in the predicate: a concurrent confirm between the read above
// and this delete must not remove a confirmed order.
const { data: deleted, error } = await supabase
.from('sales_orders')
.delete()
.eq('id', id)
.eq('company_id', companyId)
.in('status', ['draft', 'cancelled'])
.select('id')
if (!error && (!deleted || deleted.length === 0)) {
return errorResponseFromCode('SALES_ORDER_INVALID_STATE', log, {
requestId,
details: { action: 'delete', reason: 'status changed concurrently' },
})
}
if (error) {
// A makulerad (cancelled) invoice still references the order: the
// RESTRICT FK is the authority, surfaced as the structured code.
const code = codeFromPgError(error)
if (code) return errorResponseFromCode(code, log, { requestId })
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data: { id, deleted: true } })
},
{ requireWrite: true },
)
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SalesOrderTransitionSchema } from '@/lib/api/schemas'
import { transitionSalesOrder } from '@/lib/sales-orders/transitions'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/** POST /api/sales-orders/[id]/transition: confirm | cancel | reopen. */
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.transition',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, SalesOrderTransitionSchema, { log, operation: 'sales_order.transition' })
if (!validation.success) return validation.response
const result = await transitionSalesOrder(supabase, { companyId, orderId: id, action: validation.data.action })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
@@ -0,0 +1,208 @@
/**
* POST /api/sales-orders/[id]/create-invoice: unnumbered draft kundfaktura
* from an order, with the invoice builder mocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { Invoice, SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mockBuildInvoiceWriteData = vi.fn()
vi.mock('@/lib/invoices/build-invoice-write', () => ({
buildInvoiceWriteData: (...args: unknown[]) => mockBuildInvoiceWriteData(...args),
}))
import { POST } from '../[id]/create-invoice/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown = {}) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/create-invoice`, { method: 'POST', body }), params)
}
const okBuild = {
ok: true,
invoiceFields: {
customer_id: IDS.customer,
invoice_date: '2026-09-02',
due_date: '2026-10-02',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
},
items: [
{
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
unit_price: 100,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
sales_order_item_id: IDS.item1,
},
],
}
const confirmed = () =>
makeSalesOrder({
status: 'confirmed',
confirmed_at: '2026-09-01T10:00:00Z',
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10 })],
})
describe('POST /api/sales-orders/[id]/create-invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
mockBuildInvoiceWriteData.mockResolvedValue(okBuild)
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(401)
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(403)
})
it('returns 400 for a picked line with quantity 0', async () => {
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, quantity: 0 }] }),
)
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unknown mode', async () => {
const { status } = await parseJsonResponse(await post({ mode: 'everything' }))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for a draft order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
it('returns 409 SALES_ORDER_OVER_INVOICED for a pick above the remaining quantity', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [invoicedRow(IDS.item1, 7)] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, quantity: 4 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_OVER_INVOICED')
expect(body.error.details).toMatchObject({ remaining_qty: 3, requested_qty: 4 })
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('returns 409 SALES_ORDER_NOTHING_TO_INVOICE when everything is invoiced', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_NOTHING_TO_INVOICE')
})
it('creates the draft, links it to the order and answers 201 with invoice_id', async () => {
const emitted: string[] = []
eventBus.on('invoice.created', async () => {
emitted.push('invoice.created')
})
enqueue({ data: confirmed() })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.invoice, status: 'draft', invoice_number: null, sales_order_id: IDS.order } })
enqueue({ data: null }) // invoice_items insert
enqueue({ data: confirmed() }) // reload
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
const { status, body } = await parseJsonResponse<{
data: { invoice: Invoice; order: SalesOrder }
invoice_id: string
}>(await post({ invoice_date: '2026-09-02' }))
expect(status).toBe(201)
expect(body.invoice_id).toBe(IDS.invoice)
expect(body.data.invoice.id).toBe(IDS.invoice)
expect(body.data.order.invoicing_progress).toBe('full')
expect(mockBuildInvoiceWriteData).toHaveBeenCalledWith(expect.objectContaining({ documentType: 'invoice' }))
const invoiceInsert = findCall('invoices', 'insert')![0] as Record<string, unknown>
expect(invoiceInsert).toMatchObject({ sales_order_id: IDS.order, invoice_number: null, status: 'draft' })
const itemRows = findCall('invoice_items', 'insert')![0] as Record<string, unknown>[]
expect(itemRows[0]).toMatchObject({ invoice_id: IDS.invoice, sales_order_item_id: IDS.item1 })
expect(emitted).toEqual(['invoice.created'])
})
it('rolls the draft back and answers 409 when the over-invoice trigger fires', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
enqueue({ data: null, error: { message: 'SALES_ORDER_OVER_INVOICED: exceeds ordered', code: 'P0001' } })
enqueue({ data: null })
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_OVER_INVOICED')
expect(findCall('invoices', 'delete')).toBeDefined()
})
})
@@ -0,0 +1,159 @@
/**
* POST /api/sales-orders/[id]/deliver (cumulative delivered quantities).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../[id]/deliver/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/deliver`, { method: 'POST', body }), params)
}
const confirmed = (delivered = 0) =>
makeSalesOrder({
status: 'confirmed',
confirmed_at: '2026-09-01T10:00:00Z',
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: delivered })],
})
describe('POST /api/sales-orders/[id]/deliver', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an empty lines array', async () => {
const { status } = await parseJsonResponse(await post({ lines: [] }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for a negative delivered quantity', async () => {
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: -1 }] }),
)
expect(status).toBe(400)
})
it('returns 400 for a malformed delivery_date', async () => {
const { status } = await parseJsonResponse(
await post({ delivery_date: '02/09/2026', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for a draft order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
it('returns 400 SALES_ORDER_OVER_DELIVERED when delivering more than ordered', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 11 }] }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_OVER_DELIVERED')
expect(body.error.details).toMatchObject({ quantity: 10, delivered_qty: 11 })
expect(findCall('sales_order_items', 'update')).toBeUndefined()
})
it('returns 409 SALES_ORDER_INVALID_STATE when the line moved concurrently (update matched zero rows)', async () => {
enqueue({ data: confirmed(0) })
enqueue({ data: [] })
enqueue({ data: [] }) // line update: optimistic predicate on delivered_qty did not match
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ delivery_date: '2026-09-02', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 6 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ action: 'deliver', sales_order_item_id: IDS.item1 })
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('registers the delivery and answers with the reloaded order', async () => {
enqueue({ data: confirmed(0) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.item1 }] }) // line update (optimistic write matched the row)
enqueue({ data: null }) // last_delivery_date update
enqueue({ data: { ...confirmed(6), last_delivery_date: '2026-09-02' } })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await post({ delivery_date: '2026-09-02', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 6 }] }),
)
expect(status).toBe(200)
expect(body.data.last_delivery_date).toBe('2026-09-02')
expect(body.data.delivery_progress).toBe('partial')
expect(body.data.items?.[0].delivered_qty).toBe(6)
// The increased line gets the delivery date as its own last_delivery_date.
expect(findCall('sales_order_items', 'update')![0]).toEqual({
delivered_qty: 6,
last_delivery_date: '2026-09-02',
})
expect(findCall('sales_orders', 'update')![0]).toEqual({ last_delivery_date: '2026-09-02' })
})
})
@@ -0,0 +1,306 @@
/**
* GET/PATCH/DELETE /api/sales-orders/[id].
*
* Queue order for loadSalesOrder: sales_orders select, then the
* sales_order_invoiced_quantities RPC.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, PATCH, DELETE } from '../[id]/route'
const params = createMockRouteParams({ id: IDS.order })
const url = `/api/sales-orders/${IDS.order}`
function unauthenticated() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('GET /api/sales-orders/[id]', () => {
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await GET(createMockRequest(url), params))
expect(status).toBe(401)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest(url), params),
)
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns the order with sorted lines, derived quantities and a masked customer', async () => {
enqueue({
data: makeSalesOrder({
customer: makeOrderCustomer({ customer_type: 'individual', personal_number: '199001011234' }),
items: [
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, quantity: 5 }),
makeSalesOrderItem({ id: IDS.item1, sort_order: 0, quantity: 10, delivered_qty: 3 }),
],
}),
})
enqueue({ data: [invoicedRow(IDS.item1, '2')] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(await GET(createMockRequest(url), params))
expect(status).toBe(200)
expect(body.data.id).toBe(IDS.order)
expect(body.data.items?.map((i) => i.id)).toEqual([IDS.item1, IDS.item2])
expect(body.data.items?.[0]).toMatchObject({ invoiced_qty: 2, remaining_qty: 8 })
expect(body.data.items?.[1]).toMatchObject({ invoiced_qty: 0, remaining_qty: 5 })
expect(body.data.delivery_progress).toBe('partial')
expect(body.data.invoicing_progress).toBe('partial')
// The embedded customer never carries the raw personnummer out.
expect(body.data.customer?.personal_number).not.toBe('199001011234')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['id', IDS.order])
expect(findCalls('sales_orders', 'eq')).toContainEqual(['company_id', IDS.company])
})
})
describe('PATCH /api/sales-orders/[id]', () => {
function patch(body: unknown) {
return PATCH(createMockRequest(url, { method: 'PATCH', body }), params)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await patch({ notes: 'x' }))
expect(status).toBe(401)
})
it('returns 400 for an empty items array', async () => {
const { status } = await parseJsonResponse(await patch({ items: [] }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unsupported currency', async () => {
const { status } = await parseJsonResponse(await patch({ currency: 'CHF' }))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch({ notes: 'x' }))
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_NOT_EDITABLE for a completed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'completed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch({ notes: 'x' }))
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_NOT_EDITABLE')
})
it('returns 409 SALES_ORDER_LINE_LOCKED when an invoiced line is dropped', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed', items: [makeSalesOrderItem({ id: IDS.item1 })] }) })
enqueue({ data: [invoicedRow(IDS.item1, 2)] })
enqueue({ data: makeOrderCustomer() })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await patch({ items: [{ description: 'Ny rad', quantity: 1, unit: 'st', unit_price: 10, vat_rate: 25 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_LINE_LOCKED')
expect(body.error.details).toMatchObject({ sales_order_item_id: IDS.item1 })
})
it('updates the header and answers with the reloaded order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: makeSalesOrder({ status: 'draft', notes: 'Ring innan leverans' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await patch({ notes: 'Ring innan leverans' }),
)
expect(status).toBe(200)
expect(body.data.notes).toBe('Ring innan leverans')
expect(body.data.delivery_progress).toBe('none')
expect(findCall('sales_orders', 'update')![0]).toMatchObject({ notes: 'Ring innan leverans' })
})
})
describe('DELETE /api/sales-orders/[id]', () => {
function del() {
return DELETE(createMockRequest(url, { method: 'DELETE' }), params)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await del())
expect(status).toBe(401)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('returns 409 for a confirmed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await del(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ status: 'confirmed', action: 'delete' })
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('returns 409 SALES_ORDER_HAS_INVOICES when an invoice is linked', async () => {
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 1 }) // header-linked invoice
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('maps the RESTRICT FK on delete onto 409 SALES_ORDER_HAS_INVOICES (makulerad invoice still linked)', async () => {
// A cancelled invoice carries 0 invoiced quantity and is excluded from
// the header count, so the pre-checks let the delete through and the FK
// is the authority.
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 0 }) // no non-cancelled invoice
enqueue({
data: null,
error: {
code: '23503',
message:
'update or delete on table "sales_orders" violates foreign key constraint "invoices_sales_order_id_fkey" on table "invoices"',
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'delete')).toBeDefined()
})
it('maps the line-level RESTRICT FK on delete onto 409 SALES_ORDER_LINE_LOCKED', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({
data: null,
error: {
code: '23503',
message:
'update or delete on table "sales_order_items" violates foreign key constraint "invoice_items_sales_order_item_id_fkey" on table "invoice_items"',
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_LINE_LOCKED')
})
it('hard-deletes a draft with no invoices', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({ data: [{ id: IDS.order }] }) // delete (status-guarded, one row matched)
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(await del())
expect(status).toBe(200)
expect(body.data).toEqual({ id: IDS.order, deleted: true })
expect(findCall('sales_orders', 'delete')).toBeDefined()
const eqs = findCalls('sales_orders', 'eq')
expect(eqs).toContainEqual(['id', IDS.order])
expect(eqs).toContainEqual(['company_id', IDS.company])
// Status in the delete predicate: a concurrent confirm must not be deleted.
expect(findCall('sales_orders', 'in')).toEqual(['status', ['draft', 'cancelled']])
// The first sales_orders select is loadSalesOrder's projection; the delete chain's is ['id'].
expect(findCalls('sales_orders', 'select')).toContainEqual(['id'])
})
it('returns 409 SALES_ORDER_INVALID_STATE when the delete matches zero rows (confirmed concurrently)', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 0 }) // no linked invoice
enqueue({ data: [] }) // delete: the status predicate no longer matches
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await del(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ action: 'delete', reason: 'status changed concurrently' })
expect(findCall('sales_orders', 'delete')).toBeDefined()
})
it('treats a null delete result as the same conflict', async () => {
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({ data: null }) // delete
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
})
@@ -0,0 +1,199 @@
/**
* GET/POST /api/sales-orders (kundorder list + create), through the real
* withRouteContext wrapper with its auth/company/write dependencies mocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { IDS, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem, invoicedRow } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, POST } from '../route'
const noParams = { params: Promise.resolve({}) }
const validLine = { description: 'Konsulttimme', quantity: 10, unit: 'h', unit_price: 100, vat_rate: 25 }
function unauthenticated() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
describe('GET /api/sales-orders', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await GET(createMockRequest('/api/sales-orders'), noParams))
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unknown status filter', async () => {
const request = createMockRequest('/api/sales-orders', { searchParams: { status: 'shipped' } })
const { status } = await parseJsonResponse(await GET(request, noParams))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('lists orders decorated with invoiced quantities and progress', async () => {
enqueue({
data: [
makeSalesOrder({ items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 10 })] }),
makeSalesOrder({ id: 'a1000000-0000-4000-8000-000000000002', status: 'confirmed', items: [] }),
],
})
enqueue({ data: [invoicedRow(IDS.item1, '4')] })
const request = createMockRequest('/api/sales-orders', { searchParams: { status: 'draft', q: 'OR-1' } })
const { status, body } = await parseJsonResponse<{ data: SalesOrder[] }>(await GET(request, noParams))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].items?.[0]).toMatchObject({ invoiced_qty: 4, remaining_qty: 6 })
expect(body.data[0].delivery_progress).toBe('full')
expect(body.data[0].invoicing_progress).toBe('partial')
expect(body.data[1].delivery_progress).toBe('none')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['company_id', IDS.company])
expect(findCalls('sales_orders', 'eq')).toContainEqual(['status', 'draft'])
expect(findCall('sales_orders', 'ilike')).toEqual(['order_number', '%OR-1%'])
expect(supabase.rpc).toHaveBeenCalledWith('sales_order_invoiced_quantities', { p_order_ids: [IDS.order, 'a1000000-0000-4000-8000-000000000002'] })
})
it('returns an empty list without calling the RPC when there are no orders', async () => {
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await GET(createMockRequest('/api/sales-orders'), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual([])
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
describe('POST /api/sales-orders', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
function post(body: unknown) {
return POST(createMockRequest('/api/sales-orders', { method: 'POST', body }), noParams)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer, items: [validLine] }))
expect(status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer, items: [validLine] }))
expect(status).toBe(403)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 when items are missing', async () => {
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 when customer_id is not a uuid', async () => {
const { status } = await parseJsonResponse(await post({ customer_id: 'kund-1', items: [validLine] }))
expect(status).toBe(400)
})
it('returns 400 for a product line with an empty description', async () => {
const { status } = await parseJsonResponse(
await post({ customer_id: IDS.customer, items: [{ ...validLine, description: ' ' }] }),
)
expect(status).toBe(400)
})
it('returns 404 when the customer does not exist in the company', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ customer_id: IDS.customer, items: [validLine] }),
)
expect(status).toBe(404)
expect(body.error.code).toBe('CUSTOMER_NOT_FOUND')
})
it('returns 400 INVOICE_CREATE_VAT_RULE_VIOLATION for a rate the customer type forbids', async () => {
enqueue({ data: makeOrderCustomer({ customer_type: 'non_eu_business' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ customer_id: IDS.customer, items: [{ ...validLine, vat_rate: 20 }] }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('creates a draft order and answers 201 with the loaded order', async () => {
enqueue({ data: makeOrderCustomer() }) // customer lookup
enqueue({ data: { id: IDS.order } }) // header insert
enqueue({ data: null }) // lines insert
enqueue({ data: 'OR-1' }) // generate_sales_order_number
enqueue({ data: makeSalesOrder({ order_number: 'OR-1' }) }) // reload
enqueue({ data: [] }) // invoiced quantities
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await post({
customer_id: IDS.customer,
order_date: '2026-09-01',
items: [validLine, { line_type: 'text', description: 'Leverans v.36', quantity: 0, unit: '', unit_price: 0 }],
}),
)
expect(status).toBe(201)
expect(body.data.id).toBe(IDS.order)
expect(body.data.order_number).toBe('OR-1')
expect(body.data.status).toBe('draft')
expect(body.data.delivery_progress).toBe('none')
expect(body.data.invoicing_progress).toBe('none')
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
company_id: IDS.company,
user_id: IDS.user,
customer_id: IDS.customer,
status: 'draft',
subtotal: 1000,
vat_amount: 250,
total: 1250,
})
const lines = findCall('sales_order_items', 'insert')![0] as unknown[]
expect(lines).toHaveLength(2)
})
})
@@ -0,0 +1,126 @@
/**
* POST /api/sales-orders/[id]/transition (confirm | cancel | reopen).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, invoicedRow, makeSalesOrder } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../[id]/transition/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/transition`, { method: 'POST', body }), params)
}
describe('POST /api/sales-orders/[id]/transition', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post({ action: 'confirm' }))
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post({ action: 'confirm' }))
expect(status).toBe(403)
})
it('returns 400 for an unknown action', async () => {
const { status } = await parseJsonResponse(await post({ action: 'ship' }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for a missing body', async () => {
const { status } = await parseJsonResponse(await post({}))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post({ action: 'confirm' }))
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for confirm on a confirmed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ action: 'confirm' }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ status: 'confirmed', action: 'confirm' })
})
it('returns 409 SALES_ORDER_HAS_INVOICES for cancel with a linked invoice', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [invoicedRow(IDS.item1, 1)] })
enqueue({ data: [invoicedRow(IDS.item1, 1)] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post({ action: 'cancel' }))
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('confirms a draft and answers with the reloaded order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.order }] }) // CAS update
enqueue({ data: null }) // refresh_sales_order_completion
enqueue({ data: makeSalesOrder({ status: 'confirmed', confirmed_at: '2026-09-02T10:00:00Z' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(await post({ action: 'confirm' }))
expect(status).toBe(200)
expect(body.data.status).toBe('confirmed')
expect(body.data.confirmed_at).toBe('2026-09-02T10:00:00Z')
expect(body.data.delivery_progress).toBe('none')
expect(findCall('sales_orders', 'update')![0]).toMatchObject({ status: 'confirmed' })
expect(supabase.rpc).toHaveBeenCalledWith('refresh_sales_order_completion', { p_order_id: IDS.order })
})
})
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateSalesOrderSchema, SalesOrderListQuerySchema } from '@/lib/api/schemas'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
import { decorate, fetchInvoicedQuantities } from '@/lib/sales-orders/load'
import { createSalesOrder } from '@/lib/sales-orders/write'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
import { errorResponse } from '@/lib/errors/get-structured-error'
import type { SalesOrder } from '@/types'
ensureInitialized()
/**
* GET /api/sales-orders: the company's kundorder with customer, lines and
* the derived delivery/invoicing progress. Filters: status, customer_id, q
* (order number; the list page matches customer names client-side over the
* embedded customer).
*/
export const GET = withRouteContext('sales_order.list', async (request, { supabase, companyId, log, requestId }) => {
const query = validateQuery(request, SalesOrderListQuerySchema)
if (!query.success) return query.response
const { status, customer_id, q } = query.data
let orders: SalesOrder[]
try {
orders = await fetchAllRows<SalesOrder>(({ from, to }) => {
let qb = supabase
.from('sales_orders')
.select('*, customer:customers(id, name, customer_number, customer_type), items:sales_order_items(*)')
.eq('company_id', companyId)
if (status) qb = qb.eq('status', status)
if (customer_id) qb = qb.eq('customer_id', customer_id)
if (q) qb = qb.ilike('order_number', `%${q}%`)
return qb.order('order_date', { ascending: false }).order('created_at', { ascending: false }).range(from, to)
})
} catch (err) {
return errorResponse(err, log, { requestId })
}
const invoiced = await fetchInvoicedQuantities(supabase, orders.map((o) => o.id))
if (!invoiced.ok) return errorResponse(invoiced.dbError, log, { requestId })
const data = orders.map((o) => decorate(maskEmbeddedCustomer(o), invoiced.byItem))
return NextResponse.json({ data })
})
/** POST /api/sales-orders: create a draft order with lines. */
export const POST = withRouteContext(
'sales_order.create',
async (request, { supabase, user, companyId, log, requestId }) => {
const validation = await validateBody(request, CreateSalesOrderSchema, { log, operation: 'sales_order.create' })
if (!validation.success) return validation.response
const result = await createSalesOrder(supabase, { companyId, userId: user.id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order }, { status: 201 })
},
{ requireWrite: true },
)
@@ -252,7 +252,10 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => {
error: null,
},
company_settings: { data: { vat_registered: true }, error: null },
invoice_items: { data: null, error: null },
// replaceInvoiceItems snapshots the current rows before deleting and
// refuses (fails closed) when the snapshot is unreadable, so the
// mock must answer with a real (empty) row set.
invoice_items: { data: [], error: null },
},
captures,
),
@@ -464,6 +464,9 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
// cookie route and the update_invoice commit executor).
const replaced = await replaceInvoiceItems(ctx.supabase, invoiceId, build.items)
if (!replaced.ok) {
if (replaced.stage === 'guard') {
return v1ErrorResponseFromCode(replaced.code, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error(`invoice items ${replaced.stage} failed on v1 update`, replaced.error, {
invoiceId,
companyId: ctx.companyId,