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:
co-authored by
Claude Fable 5.1
parent
c40e63e3f7
commit
c0818bb2d2
@@ -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 },
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user