Files
accounted/lib/sales-orders/__tests__/write.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

513 lines
20 KiB
TypeScript

/**
* createSalesOrder / updateSalesOrder with the queued Supabase mock.
*
* updateSalesOrder queue order: loadSalesOrder (sales_orders select,
* invoiced rpc), then ONLY when customer_id or currency changes:
* hasOpenInvoices (invoiced rpc, and an invoices head count when no line
* carries invoiced quantity), then customers select, sales_orders update,
* then when lines are given: sales_order_items delete (only if any
* dropped), one sales_order_items update per kept line, one insert for new
* lines, and a final loadSalesOrder.
*
* The header update is an object literal with every column present (an
* omitted input leaves undefined, which PostgREST drops), so assertions use
* toMatchObject and check the untouched keys are undefined explicitly.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { createSalesOrder, hasOpenInvoices, updateSalesOrder } from '../write'
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from './fixtures'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const sb = supabase as unknown as SupabaseClient
const baseLine = { description: 'Konsulttimme', quantity: 10, unit: 'h', unit_price: 100, vat_rate: 25 }
describe('updateSalesOrder', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('returns SALES_ORDER_NOT_FOUND for a missing order', async () => {
enqueue({ data: null })
const result = await updateSalesOrder(sb, { companyId: IDS.company, orderId: IDS.order, input: { notes: 'x' } })
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_NOT_FOUND' })
})
it('refuses edits on a completed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'completed', completed_at: '2026-09-02T10:00:00Z' }) })
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
const result = await updateSalesOrder(sb, { companyId: IDS.company, orderId: IDS.order, input: { notes: 'Sen' } })
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_NOT_EDITABLE', details: { status: 'completed' } })
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('refuses edits on a cancelled order', async () => {
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
const result = await updateSalesOrder(sb, { companyId: IDS.company, orderId: IDS.order, input: { notes: 'Sen' } })
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_NOT_EDITABLE' })
})
it('refuses dropping a line that has invoiced quantity', async () => {
enqueue({
data: makeSalesOrder({
status: 'confirmed',
items: [
makeSalesOrderItem({ id: IDS.item1, quantity: 10 }),
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, description: 'Licens', quantity: 2 }),
],
}),
})
enqueue({ data: [invoicedRow(IDS.item1, 3)] })
enqueue({ data: makeOrderCustomer() })
// Only item2 comes back: item1 (3 invoiced) would be deleted.
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.item2, ...baseLine, description: 'Licens', quantity: 2 }] },
})
expect(result).toMatchObject({
ok: false,
code: 'SALES_ORDER_LINE_LOCKED',
details: { sales_order_item_id: IDS.item1 },
})
expect(findCall('sales_orders', 'update')).toBeUndefined()
expect(findCall('sales_order_items', 'delete')).toBeUndefined()
})
it('refuses dropping a line that has delivered quantity', async () => {
enqueue({
data: makeSalesOrder({
status: 'confirmed',
items: [
makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 1 }),
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, quantity: 2 }),
],
}),
})
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.item2, ...baseLine, quantity: 2 }] },
})
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_LINE_LOCKED' })
})
it('refuses lowering a line below its delivered quantity', async () => {
enqueue({
data: makeSalesOrder({
status: 'confirmed',
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 5 })],
}),
})
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.item1, ...baseLine, quantity: 3 }] },
})
expect(result).toMatchObject({
ok: false,
code: 'SALES_ORDER_OVER_DELIVERED',
details: { sales_order_item_id: IDS.item1, delivered_qty: 5 },
})
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('refuses lowering a line below its invoiced quantity', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed', items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10 })] }) })
enqueue({ data: [invoicedRow(IDS.item1, 4)] })
enqueue({ data: makeOrderCustomer() })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.item1, ...baseLine, quantity: 3 }] },
})
expect(result).toMatchObject({
ok: false,
code: 'SALES_ORDER_QUANTITY_BELOW_INVOICED',
details: { sales_order_item_id: IDS.item1, invoiced_qty: 4 },
})
})
it('refuses a line id that does not belong to the order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.unknownItem, ...baseLine }] },
})
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_LINE_NOT_FOUND' })
})
it('returns CUSTOMER_NOT_FOUND when the new customer is not in the company', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: null })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { customer_id: IDS.otherCustomer },
})
expect(result).toMatchObject({ ok: false, code: 'CUSTOMER_NOT_FOUND' })
})
it('re-validates stored lines when only the customer changes (25 % line to a validated EU business)', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft', items: [makeSalesOrderItem({ vat_rate: 25 })] }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices: invoiced rpc (nothing invoiced)
enqueue({ data: null, count: 0 }) // hasOpenInvoices: no header-linked invoice
enqueue({
data: makeOrderCustomer({
id: IDS.otherCustomer,
customer_type: 'eu_business',
vat_number: 'DE123456789',
vat_number_validated: true,
}),
})
enqueue({ data: null }) // header update
enqueue({ data: makeSalesOrder({ status: 'draft', customer_id: IDS.otherCustomer }) })
enqueue({ data: [] })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { customer_id: IDS.otherCustomer },
})
// 25 % is permitted for a validated EU business (taxed where performed),
// so the customer change goes through and only the header is written,
// with the snapshot refreshed to the NEW customer's VAT facts.
expect(result.ok).toBe(true)
expect(findCall('sales_orders', 'update')![0]).toMatchObject({
customer_id: IDS.otherCustomer,
customer_type_snapshot: 'eu_business',
customer_vat_validated_snapshot: true,
})
expect(findCall('sales_order_items', 'update')).toBeUndefined()
expect(findCalls('invoices', 'eq')).toContainEqual(['sales_order_id', IDS.order])
})
it('refuses a customer change with SALES_ORDER_HAS_INVOICES once a line has invoiced quantity', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed', items: [makeSalesOrderItem({ id: IDS.item1 })] }) })
enqueue({ data: [invoicedRow(IDS.item1, 2)] }) // loadSalesOrder
enqueue({ data: [invoicedRow(IDS.item1, 2)] }) // hasOpenInvoices rpc
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { customer_id: IDS.otherCustomer, notes: 'Byt kund' },
})
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_HAS_INVOICES', details: { field: 'customer_id' } })
// The check runs before the customer is even loaded and nothing is written.
expect(findCall('customers', 'select')).toBeUndefined()
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('refuses a currency change when a header-linked invoice exists even with zero invoiced quantity', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc: nothing invoiced
enqueue({ data: null, count: 1 }) // but an invoice still points at the order
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { currency: 'EUR' },
})
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_HAS_INVOICES', details: { field: 'currency' } })
expect(findCall('customers', 'select')).toBeUndefined()
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('skips the open-invoice check when customer_id and currency are unchanged', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [invoicedRow(IDS.item1, 2)] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: makeSalesOrder({ status: 'confirmed', notes: 'Samma kund' }) })
enqueue({ data: [invoicedRow(IDS.item1, 2)] })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { customer_id: IDS.customer, currency: 'SEK', notes: 'Samma kund' },
})
expect(result.ok).toBe(true)
// Two rpc calls: the two loadSalesOrder calls, no hasOpenInvoices in between.
expect(supabase.rpc).toHaveBeenCalledTimes(2)
expect(findCall('invoices', 'select')).toBeUndefined()
})
it('updates header only when no lines are given, keeping stored totals', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft', subtotal: 1000, vat_amount: 250, total: 1250 }) })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: makeSalesOrder({ status: 'draft', notes: 'Leverans till lagret' }) })
enqueue({ data: [] })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { notes: 'Leverans till lagret', requested_delivery_date: '2026-09-15' },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.order.notes).toBe('Leverans till lagret')
const headerUpdate = findCall('sales_orders', 'update')![0] as Record<string, unknown>
expect(headerUpdate).toMatchObject({
subtotal: 1000,
vat_amount: 250,
total: 1250,
notes: 'Leverans till lagret',
requested_delivery_date: '2026-09-15',
customer_type_snapshot: 'swedish_business',
customer_vat_validated_snapshot: false,
})
// Omitted inputs stay undefined so PostgREST leaves the columns alone.
for (const key of ['customer_id', 'currency', 'order_date', 'your_reference', 'our_reference', 'default_dimensions']) {
expect(headerUpdate).toHaveProperty(key)
expect(headerUpdate[key]).toBeUndefined()
}
expect(findCall('sales_order_items', 'update')).toBeUndefined()
expect(findCall('sales_order_items', 'insert')).toBeUndefined()
})
it('replaces lines: updates kept lines by id, inserts new ones, deletes omitted ones, recomputes totals', async () => {
enqueue({
data: makeSalesOrder({
status: 'confirmed',
items: [
makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 2 }),
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, description: 'Bortplockad', quantity: 1 }),
],
}),
})
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: null }) // delete item2
enqueue({ data: null }) // update item1
enqueue({ data: null }) // insert new line
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: {
items: [
{ id: IDS.item1, ...baseLine, quantity: 12 },
{ description: 'Resa', quantity: 1, unit: 'st', unit_price: 500, vat_rate: 6 },
],
},
})
expect(result.ok).toBe(true)
// 12 x 100 @ 25 % + 500 @ 6 %
const headerUpdate = findCall('sales_orders', 'update')![0] as Record<string, unknown>
expect(headerUpdate).toMatchObject({
subtotal: 1700,
vat_amount: 330,
total: 2030,
customer_type_snapshot: 'swedish_business',
customer_vat_validated_snapshot: false,
})
expect(headerUpdate.customer_id).toBeUndefined()
expect(headerUpdate.notes).toBeUndefined()
expect(findCall('sales_order_items', 'in')).toEqual(['id', [IDS.item2]])
const lineUpdate = findCall('sales_order_items', 'update')![0] as Record<string, unknown>
expect(lineUpdate).toMatchObject({ quantity: 12, sort_order: 0, line_total: 1200 })
expect(lineUpdate).not.toHaveProperty('id')
const inserted = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(inserted).toHaveLength(1)
expect(inserted[0]).toMatchObject({
description: 'Resa',
sort_order: 1,
vat_rate: 6,
line_total: 500,
company_id: IDS.company,
sales_order_id: IDS.order,
})
expect(inserted[0]).not.toHaveProperty('id')
})
it('maps the quantity-below-invoiced trigger onto SALES_ORDER_QUANTITY_BELOW_INVOICED (race)', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] }) // pre-check sees nothing invoiced
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: null, error: { message: 'SALES_ORDER_QUANTITY_BELOW_INVOICED: line d1 has 4 invoiced' } })
const result = await updateSalesOrder(sb, {
companyId: IDS.company,
orderId: IDS.order,
input: { items: [{ id: IDS.item1, ...baseLine, quantity: 3 }] },
})
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_QUANTITY_BELOW_INVOICED' })
})
})
describe('createSalesOrder', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('returns CUSTOMER_NOT_FOUND when the customer is not in the company', async () => {
enqueue({ data: null })
const result = await createSalesOrder(sb, {
companyId: IDS.company,
userId: IDS.user,
input: { customer_id: IDS.customer, items: [baseLine] },
})
expect(result).toMatchObject({ ok: false, code: 'CUSTOMER_NOT_FOUND', details: { customerId: IDS.customer } })
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('propagates the VAT gate before any insert', async () => {
enqueue({ data: makeOrderCustomer({ customer_type: 'non_eu_business' }) })
const result = await createSalesOrder(sb, {
companyId: IDS.company,
userId: IDS.user,
input: { customer_id: IDS.customer, items: [{ ...baseLine, vat_rate: 20 }] },
})
expect(result).toMatchObject({ ok: false, code: 'INVOICE_CREATE_VAT_RULE_VIOLATION' })
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('inserts header + lines, numbers the order and reloads it', async () => {
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } }) // header insert
enqueue({ data: null }) // items insert
enqueue({ data: 'OR-1' }) // generate_sales_order_number
enqueue({ data: makeSalesOrder({ status: 'draft', order_number: 'OR-1' }) })
enqueue({ data: [] })
const result = await createSalesOrder(sb, {
companyId: IDS.company,
userId: IDS.user,
input: {
customer_id: IDS.customer,
order_date: '2026-09-01',
items: [baseLine, { line_type: 'text', description: 'Tack för din order', quantity: 0, unit: '', unit_price: 0 }],
},
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.order.order_number).toBe('OR-1')
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
company_id: IDS.company,
user_id: IDS.user,
customer_id: IDS.customer,
status: 'draft',
source_invoice_id: null,
order_date: '2026-09-01',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
// The customer facts the lines were VAT-validated under travel with the order.
customer_type_snapshot: 'swedish_business',
customer_vat_validated_snapshot: false,
})
const lines = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ sales_order_id: IDS.order, company_id: IDS.company, line_type: 'product' })
expect(lines[1]).toMatchObject({ line_type: 'text', quantity: 0, line_total: 0 })
expect(supabase.rpc).toHaveBeenCalledWith('generate_sales_order_number', {
p_company_id: IDS.company,
p_order_id: IDS.order,
})
expect(findCalls('sales_orders', 'eq')).toContainEqual(['id', IDS.order])
})
it('rolls the header back when the line insert fails', async () => {
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null, error: { message: 'insert failed', code: '23502' } })
enqueue({ data: null }) // header delete
const result = await createSalesOrder(sb, {
companyId: IDS.company,
userId: IDS.user,
input: { customer_id: IDS.customer, items: [baseLine] },
})
expect(result.ok).toBe(false)
expect('dbError' in result).toBe(true)
expect(findCall('sales_orders', 'delete')).toBeDefined()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('still returns the order when numbering fails (number assigned on the next write)', async () => {
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null })
enqueue({ data: null, error: { message: 'counter locked' } }) // rpc fails
enqueue({ data: makeSalesOrder({ status: 'draft', order_number: null }) })
enqueue({ data: [] })
const result = await createSalesOrder(sb, {
companyId: IDS.company,
userId: IDS.user,
input: { customer_id: IDS.customer, items: [baseLine] },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.order.order_number).toBeNull()
})
})
describe('hasOpenInvoices', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('is open as soon as any line carries invoiced quantity (no header count needed)', async () => {
enqueue({ data: [invoicedRow(IDS.item1, '1')] })
const result = await hasOpenInvoices(sb, IDS.company, IDS.order)
expect(result).toEqual({ ok: true, open: true })
expect(supabase.from).not.toHaveBeenCalled()
})
it('falls back to a header count when no line is invoiced', async () => {
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
const result = await hasOpenInvoices(sb, IDS.company, IDS.order)
expect(result).toEqual({ ok: true, open: false })
expect(findCalls('invoices', 'eq')).toContainEqual(['sales_order_id', IDS.order])
})
})