* feat(invoices): offert to kundorder, the missing step in offert, order, faktura "Skapa order" on an open or accepted quote creates a draft kundorder from its lines. The quote stays as the customer's accepted agreement (flips to quote_status accepted with a compare-and-set on the decision that was read); the order is delivered and invoiced, in full or in parts, from the kundorder page. Declined quotes are refused. Same action on the MCP side: gnubok_convert_invoice takes target 'order', staged under the existing convert_invoice operation type. Why the problem occurred: the proforma -> order conversion refused every source that was not a proforma, so the offert, which is what users actually send before an order, could only become an invoice. The product had both ends of the Fortnox flow (offert, kundorder) but no bridge. What was removed or simplified: no second service and no new operation type. The proforma conversion became the document conversion (lib/sales-orders/convert-to-sales-order.ts) with the quote source as a branch on the source update, mirroring how convertToInvoice already treats the two. The MCP surface is one tool with a target parameter rather than a sibling tool, which also gives proforma -> order the MCP surface it did not have. Why this shape: the sale must never exist twice. A quote with a live converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED), and a quote with a live kundorder cannot become an invoice a second time (new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A cancelled order or invoice frees the quote again. Rejected: cancelling the quote like the proforma path (hides the accepted agreement), a separate gnubok_convert_quote_to_order tool, and refusing expired quotes (the invoice path allows them behind a confirm; the order path does the same). Fixes #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxwavqBoG1HwFD5znkCGLv * fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate Skeptic refutations on the offert -> kundorder change: 1. An already-accepted quote could be converted twice concurrently (two orders, or an order and an invoice): the services' pre-checks are not serialized and the accepted -> accepted compare-and-set matches for every caller. Migration 20260908152555 adds a partial unique index (one live kundorder per source document) and two BEFORE triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa, so concurrent conversions queue and the second one sees the first. The services map the raised codes onto the same 409s the pre-checks use. pg-real test covers the index, both directions, reopen from cancelled, the member-session lock, and the concurrent pair on two connections. 2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a NULL exchange rate when Riksbanken had none, which resolveSekAmount() then posts 1:1 as kronor. Pre-existing, but the quote now depends on the order path and the fail-closed quote -> invoice route is refused while an order lives. The order path now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order The approval dialog's consequence sentence was keyed on operation_type alone and promised a faktura with F-number for every convert_invoice. With target 'order' the commit creates a draft kundorder and books nothing, so the sentence now reads the params (skeptic refutation). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page Correctness skeptic refutations on the offert -> kundorder change: 1. A quote with a live kundorder could still be set to open or declined (dashboard route, v1, MCP): the decision guard only knew converted invoices. The dashboard then hid the re-accept button, so the quote was stuck as "Avböjd" behind a confirmed, invoiced order. Migration 20260908155231 extends invoices_quote_decision_guard to refuse leaving accepted while a live kundorder points at the quote (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code. 2. The two source guards from 20260908152555 locked the quote row with a SELECT FOR UPDATE as the invoker. Under RLS that also applies the UPDATE policy, which admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. All three guard functions are now SECURITY DEFINER. pg-real test covers the non-active company and the decision lock. 3. The kundorder page labelled every source "Proformafaktura". It now loads the source document and shows "Offert OF-nnn" for a quote; the MCP field description and the type comment say proforma or quote. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards CI: the target parameter and two description edits pushed the projected tools/list payload to 60 502 tokens against the 60 500 ceiling; the same facts now fit in fewer words (ceiling unchanged). Superagent P2: the source guards run as definer since 20260908155231, so a source_invoice_id or converted_from_id pointing at another company's document would have locked and inspected that row. Both guards now require the source to belong to the row's company and refuse otherwise (SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH), covered by a cross-company pg-real case. Migration 20260908155231 was re-applied to staging under the same version (never on prod). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move the quote conversion guards to versions after main's 20260908164944 Main merged a later version while this branch was open; Supabase applies pending versions in order, so both files are renamed to fresh versions (20260908165000, 20260908165100) and re-tracked on staging under those. Byte-identical SQL. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
608 lines
24 KiB
TypeScript
608 lines
24 KiB
TypeScript
/**
|
|
* pickLines (pure) and createInvoiceFromSalesOrder (queued mock, with the
|
|
* invoice builder mocked).
|
|
*
|
|
* createInvoiceFromSalesOrder queue order: loadSalesOrder (sales_orders
|
|
* select, invoiced rpc), customers select, invoices insert, invoice_items
|
|
* insert, then loadSalesOrder again. On an items-insert failure the two
|
|
* rollback deletes (invoice_items, invoices) come before the failure returns.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from './fixtures'
|
|
|
|
const mockBuildInvoiceWriteData = vi.fn()
|
|
vi.mock('@/lib/invoices/build-invoice-write', () => ({
|
|
buildInvoiceWriteData: (...args: unknown[]) => mockBuildInvoiceWriteData(...args),
|
|
}))
|
|
|
|
import { createInvoiceFromSalesOrder, deliveryDateFor, pickLines } from '../create-invoice-from-order'
|
|
|
|
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
|
const sb = supabase as unknown as SupabaseClient
|
|
|
|
function orderWith(items = [makeSalesOrderItem()], overrides = {}) {
|
|
return makeSalesOrder({ status: 'confirmed', confirmed_at: '2026-09-01T10:00:00Z', items, ...overrides })
|
|
}
|
|
|
|
describe('pickLines', () => {
|
|
const order = makeSalesOrder({
|
|
items: [
|
|
makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 5, invoiced_qty: 2, remaining_qty: 8 }),
|
|
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, quantity: 4, delivered_qty: 0, invoiced_qty: 4, remaining_qty: 0 }),
|
|
makeSalesOrderItem({ id: IDS.item3, sort_order: 2, line_type: 'text', quantity: 0, delivered_qty: 0 }),
|
|
],
|
|
})
|
|
|
|
it('refuses an explicit pick above the remaining quantity', () => {
|
|
const result = pickLines(order, { lines: [{ sales_order_item_id: IDS.item1, quantity: 9 }] })
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_OVER_INVOICED',
|
|
details: { sales_order_item_id: IDS.item1, remaining_qty: 8, requested_qty: 9 },
|
|
})
|
|
})
|
|
|
|
it('accepts an explicit pick at exactly the remaining quantity', () => {
|
|
const result = pickLines(order, { lines: [{ sales_order_item_id: IDS.item1, quantity: 8 }] })
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.picked).toHaveLength(1)
|
|
expect(result.picked[0]).toMatchObject({ quantity: 8, item: { id: IDS.item1 } })
|
|
})
|
|
|
|
it('derives remaining from quantity - invoiced_qty when remaining_qty is absent', () => {
|
|
const bare = makeSalesOrder({ items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, invoiced_qty: 7 })] })
|
|
expect(pickLines(bare, { lines: [{ sales_order_item_id: IDS.item1, quantity: 4 }] })).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_OVER_INVOICED',
|
|
details: { remaining_qty: 3 },
|
|
})
|
|
})
|
|
|
|
it('refuses an explicit pick of a text row or an unknown line', () => {
|
|
expect(pickLines(order, { lines: [{ sales_order_item_id: IDS.item3, quantity: 1 }] })).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_LINE_NOT_FOUND',
|
|
})
|
|
expect(pickLines(order, { lines: [{ sales_order_item_id: IDS.unknownItem, quantity: 1 }] })).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_LINE_NOT_FOUND',
|
|
details: { sales_order_item_id: IDS.unknownItem },
|
|
})
|
|
})
|
|
|
|
it('mode remaining (default) picks everything not yet invoiced', () => {
|
|
const result = pickLines(order, {})
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
// item2 is fully invoiced, the text row never counts.
|
|
expect(result.picked.map((p) => [p.item.id, p.quantity])).toEqual([[IDS.item1, 8]])
|
|
})
|
|
|
|
it('mode delivered picks delivered minus invoiced, capped at remaining', () => {
|
|
const result = pickLines(order, { mode: 'delivered' })
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
// item1: delivered 5 - invoiced 2 = 3
|
|
expect(result.picked.map((p) => [p.item.id, p.quantity])).toEqual([[IDS.item1, 3]])
|
|
})
|
|
|
|
it('mode delivered never goes negative when more is invoiced than delivered', () => {
|
|
const advance = makeSalesOrder({
|
|
items: [
|
|
makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 1, invoiced_qty: 5 }),
|
|
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, quantity: 10, delivered_qty: 6, invoiced_qty: 5 }),
|
|
],
|
|
})
|
|
const result = pickLines(advance, { mode: 'delivered' })
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.picked.map((p) => [p.item.id, p.quantity])).toEqual([[IDS.item2, 1]])
|
|
})
|
|
|
|
it('returns SALES_ORDER_NOTHING_TO_INVOICE when every line is fully invoiced', () => {
|
|
const done = makeSalesOrder({
|
|
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 4, invoiced_qty: 4, remaining_qty: 0 })],
|
|
})
|
|
expect(pickLines(done, {})).toMatchObject({ ok: false, code: 'SALES_ORDER_NOTHING_TO_INVOICE' })
|
|
expect(pickLines(done, { mode: 'delivered' })).toMatchObject({ ok: false, code: 'SALES_ORDER_NOTHING_TO_INVOICE' })
|
|
})
|
|
|
|
it('returns SALES_ORDER_NOTHING_TO_INVOICE in delivered mode when nothing was delivered', () => {
|
|
const undelivered = makeSalesOrder({ items: [makeSalesOrderItem({ quantity: 4, delivered_qty: 0 })] })
|
|
expect(pickLines(undelivered, { mode: 'delivered' })).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_NOTHING_TO_INVOICE',
|
|
})
|
|
})
|
|
|
|
it('treats an empty explicit lines array like no picks', () => {
|
|
const result = pickLines(order, { lines: [] })
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.picked.map((p) => p.item.id)).toEqual([IDS.item1])
|
|
})
|
|
|
|
it('sums duplicate picks of the same line into one invoice line', () => {
|
|
const result = pickLines(order, {
|
|
lines: [
|
|
{ sales_order_item_id: IDS.item1, quantity: 3 },
|
|
{ sales_order_item_id: IDS.item1, quantity: 5 },
|
|
],
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.picked).toHaveLength(1)
|
|
expect(result.picked[0]).toMatchObject({ quantity: 8, item: { id: IDS.item1 } })
|
|
})
|
|
|
|
it('refuses duplicate picks whose SUM exceeds the remaining quantity', () => {
|
|
// 5 and 4 each fit within the remaining 8; together they do not.
|
|
const result = pickLines(order, {
|
|
lines: [
|
|
{ sales_order_item_id: IDS.item1, quantity: 5 },
|
|
{ sales_order_item_id: IDS.item1, quantity: 4 },
|
|
],
|
|
})
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_OVER_INVOICED',
|
|
details: { sales_order_item_id: IDS.item1, remaining_qty: 8, requested_qty: 9 },
|
|
})
|
|
})
|
|
|
|
it('accepts a pick equal to a float remainder (7.5 ordered, 6.9 invoiced, pick 0.6)', () => {
|
|
// 7.5 - 6.9 is 0.5999999999999996 in doubles; the pick must still fit.
|
|
const fractional = makeSalesOrder({
|
|
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 7.5, invoiced_qty: 6.9 })],
|
|
})
|
|
const explicit = pickLines(fractional, { lines: [{ sales_order_item_id: IDS.item1, quantity: 0.6 }] })
|
|
expect(explicit.ok).toBe(true)
|
|
if (!explicit.ok) return
|
|
expect(explicit.picked[0].quantity).toBe(0.6)
|
|
|
|
const remaining = pickLines(fractional, {})
|
|
expect(remaining.ok).toBe(true)
|
|
if (!remaining.ok) return
|
|
expect(remaining.picked[0].quantity).toBe(0.6)
|
|
})
|
|
|
|
it('rounds a summed fractional pick before comparing it to the remainder', () => {
|
|
const fractional = makeSalesOrder({
|
|
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 1, invoiced_qty: 0.7 })],
|
|
})
|
|
// 0.1 + 0.2 = 0.30000000000000004 in doubles; remaining is exactly 0.3.
|
|
const result = pickLines(fractional, {
|
|
lines: [
|
|
{ sales_order_item_id: IDS.item1, quantity: 0.1 },
|
|
{ sales_order_item_id: IDS.item1, quantity: 0.2 },
|
|
],
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.picked[0].quantity).toBe(0.3)
|
|
})
|
|
})
|
|
|
|
describe('deliveryDateFor', () => {
|
|
it('is the latest per-line last_delivery_date when every pick is covered by deliveries', () => {
|
|
const picked = [
|
|
{
|
|
item: makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 5, invoiced_qty: 2, last_delivery_date: '2026-08-20' }),
|
|
quantity: 3,
|
|
},
|
|
{
|
|
item: makeSalesOrderItem({ id: IDS.item2, quantity: 4, delivered_qty: 4, invoiced_qty: 0, last_delivery_date: '2026-08-30' }),
|
|
quantity: 4,
|
|
},
|
|
]
|
|
expect(deliveryDateFor(picked)).toBe('2026-08-30')
|
|
})
|
|
|
|
it('is null when a pick exceeds what was delivered but not yet invoiced (advance invoice)', () => {
|
|
const picked = [
|
|
{
|
|
// delivered 5 - invoiced 2 = 3 available; picking 4 reaches undelivered quantity.
|
|
item: makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 5, invoiced_qty: 2, last_delivery_date: '2026-08-20' }),
|
|
quantity: 4,
|
|
},
|
|
]
|
|
expect(deliveryDateFor(picked)).toBeNull()
|
|
expect(
|
|
deliveryDateFor([
|
|
{ item: makeSalesOrderItem({ quantity: 10, delivered_qty: 0, last_delivery_date: null }), quantity: 1 },
|
|
]),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('is null when any covered line lacks a per-line delivery date', () => {
|
|
const picked = [
|
|
{
|
|
item: makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 5, invoiced_qty: 0, last_delivery_date: '2026-08-20' }),
|
|
quantity: 5,
|
|
},
|
|
{
|
|
// Delivered before per-line dates existed: no date to anchor on.
|
|
item: makeSalesOrderItem({ id: IDS.item2, quantity: 4, delivered_qty: 4, invoiced_qty: 0, last_delivery_date: null }),
|
|
quantity: 4,
|
|
},
|
|
]
|
|
expect(deliveryDateFor(picked)).toBeNull()
|
|
})
|
|
|
|
it('tolerates float drift in the covered check (7.5 delivered, 6.9 invoiced, pick 0.6)', () => {
|
|
const picked = [
|
|
{
|
|
item: makeSalesOrderItem({ id: IDS.item1, quantity: 7.5, delivered_qty: 7.5, invoiced_qty: 6.9, last_delivery_date: '2026-08-30' }),
|
|
quantity: 0.6,
|
|
},
|
|
]
|
|
expect(deliveryDateFor(picked)).toBe('2026-08-30')
|
|
})
|
|
})
|
|
|
|
const okBuild = {
|
|
ok: true,
|
|
invoiceFields: {
|
|
customer_id: IDS.customer,
|
|
invoice_date: '2026-09-02',
|
|
due_date: '2026-10-02',
|
|
currency: 'SEK',
|
|
subtotal: 800,
|
|
vat_amount: 200,
|
|
total: 1000,
|
|
vat_treatment: 'standard_25',
|
|
},
|
|
items: [
|
|
{
|
|
sort_order: 0,
|
|
line_type: 'product',
|
|
description: 'Konsulttimme',
|
|
quantity: 8,
|
|
unit: 'h',
|
|
unit_price: 100,
|
|
line_total: 800,
|
|
vat_rate: 25,
|
|
vat_amount: 200,
|
|
sales_order_item_id: IDS.item1,
|
|
},
|
|
],
|
|
}
|
|
|
|
describe('createInvoiceFromSalesOrder', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
mockBuildInvoiceWriteData.mockResolvedValue(okBuild)
|
|
})
|
|
|
|
const params = { companyId: IDS.company, userId: IDS.user, orderId: IDS.order }
|
|
|
|
it('returns SALES_ORDER_NOT_FOUND for a missing order', async () => {
|
|
enqueue({ data: null })
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_NOT_FOUND' })
|
|
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses invoicing an order that is not confirmed', async () => {
|
|
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
|
|
enqueue({ data: [] })
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_INVALID_STATE',
|
|
details: { status: 'draft', action: 'invoice' },
|
|
})
|
|
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses when nothing remains to invoice', async () => {
|
|
enqueue({ data: orderWith([makeSalesOrderItem({ id: IDS.item1, quantity: 4 })]) })
|
|
enqueue({ data: [invoicedRow(IDS.item1, 4)] })
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_NOTHING_TO_INVOICE' })
|
|
expect(findCall('invoices', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE when a foreign-currency order gets no Riksbanken rate', async () => {
|
|
enqueue({ data: orderWith([makeSalesOrderItem()], { currency: 'EUR' }) })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
// The builder leaves exchange_rate NULL on a miss; booking that 1:1 as
|
|
// kronor is exactly what convertToInvoice refuses, so this path must too.
|
|
mockBuildInvoiceWriteData.mockResolvedValue({
|
|
...okBuild,
|
|
invoiceFields: { ...okBuild.invoiceFields, currency: 'EUR', exchange_rate: null, exchange_rate_date: null, total_sek: null },
|
|
})
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE', details: { currency: 'EUR' } })
|
|
expect(findCall('invoices', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('still creates the invoice for a foreign-currency order when the rate is present', async () => {
|
|
enqueue({ data: orderWith([makeSalesOrderItem()], { currency: 'EUR' }) })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft', invoice_number: null, sales_order_id: IDS.order } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
|
|
mockBuildInvoiceWriteData.mockResolvedValue({
|
|
...okBuild,
|
|
invoiceFields: { ...okBuild.invoiceFields, currency: 'EUR', exchange_rate: 11.45, exchange_rate_date: '2026-09-02' },
|
|
})
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(findCall('invoices', 'insert')![0]).toMatchObject({ currency: 'EUR', exchange_rate: 11.45 })
|
|
})
|
|
|
|
it('returns CUSTOMER_NOT_FOUND when the raw customer row is gone', async () => {
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: null })
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
expect(result).toMatchObject({ ok: false, code: 'CUSTOMER_NOT_FOUND', details: { customerId: IDS.customer } })
|
|
})
|
|
|
|
it('creates an unnumbered draft linked to the order, each line carrying its sales_order_item_id', async () => {
|
|
enqueue({
|
|
data: orderWith(
|
|
[
|
|
makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 0, discount_percent: 10 }),
|
|
makeSalesOrderItem({ id: IDS.item3, sort_order: 1, line_type: 'text', description: 'Tack', quantity: 0 }),
|
|
],
|
|
{ order_number: 'OR-7', your_reference: 'Anna', last_delivery_date: '2026-08-30' },
|
|
),
|
|
})
|
|
enqueue({ data: [invoicedRow(IDS.item1, 2)] })
|
|
enqueue({ data: makeOrderCustomer({ default_payment_terms: 20 }) })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft', invoice_number: null, sales_order_id: IDS.order } })
|
|
enqueue({ data: null }) // invoice_items insert
|
|
enqueue({ data: orderWith() }) // reload
|
|
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: { invoice_date: '2026-09-02' } })
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.invoice.id).toBe(IDS.invoice)
|
|
expect(result.order.invoicing_progress).toBe('full')
|
|
|
|
// Builder input: the remaining 8 of item1, the text row carried along,
|
|
// sales_order_item_id on the product line, due date from customer terms,
|
|
// no delivery date because nothing was delivered.
|
|
const buildArg = mockBuildInvoiceWriteData.mock.calls[0][0] as {
|
|
documentType: string
|
|
input: { items: Record<string, unknown>[]; due_date: string; delivery_date: unknown; notes?: string; your_reference?: string }
|
|
}
|
|
expect(buildArg.documentType).toBe('invoice')
|
|
expect(buildArg.input.due_date).toBe('2026-09-22')
|
|
expect(buildArg.input.delivery_date).toBeNull()
|
|
expect(buildArg.input.notes).toBe('Kundorder OR-7')
|
|
expect(buildArg.input.your_reference).toBe('Anna')
|
|
expect(buildArg.input.items).toEqual([
|
|
expect.objectContaining({
|
|
line_type: 'product',
|
|
quantity: 8,
|
|
unit_price: 100,
|
|
discount_percent: 10,
|
|
vat_rate: 25,
|
|
sales_order_item_id: IDS.item1,
|
|
}),
|
|
expect.objectContaining({ line_type: 'text', description: 'Tack', quantity: 0 }),
|
|
])
|
|
|
|
const invoiceInsert = findCall('invoices', 'insert')![0] as Record<string, unknown>
|
|
expect(invoiceInsert).toMatchObject({
|
|
user_id: IDS.user,
|
|
company_id: IDS.company,
|
|
invoice_number: null,
|
|
status: 'draft',
|
|
sales_order_id: IDS.order,
|
|
subtotal: 800,
|
|
total: 1000,
|
|
})
|
|
|
|
const itemRows = findCall('invoice_items', 'insert')![0] as Record<string, unknown>[]
|
|
expect(itemRows).toHaveLength(1)
|
|
for (const row of itemRows) {
|
|
expect(row.invoice_id).toBe(IDS.invoice)
|
|
expect(row.sales_order_item_id).toBe(IDS.item1)
|
|
}
|
|
expect(findCall('invoices', 'delete')).toBeUndefined()
|
|
})
|
|
|
|
it('sets delivery_date from the picked lines when the pick is covered by deliveries', async () => {
|
|
// The header last_delivery_date is display-only and deliberately later
|
|
// than the line's date: the invoice must take the LINE date.
|
|
enqueue({
|
|
data: orderWith(
|
|
[makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 4, last_delivery_date: '2026-08-30' })],
|
|
{ last_delivery_date: '2026-09-01' },
|
|
),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, {
|
|
...params,
|
|
input: { mode: 'delivered', due_date: '2026-09-30' },
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
const buildArg = mockBuildInvoiceWriteData.mock.calls[0][0] as { input: { delivery_date: unknown; due_date: string; items: { quantity: number }[] } }
|
|
expect(buildArg.input.delivery_date).toBe('2026-08-30')
|
|
expect(buildArg.input.due_date).toBe('2026-09-30')
|
|
expect(buildArg.input.items[0].quantity).toBe(4)
|
|
})
|
|
|
|
it('leaves delivery_date null when the pick reaches undelivered quantity (advance invoice)', async () => {
|
|
enqueue({
|
|
data: orderWith(
|
|
[makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 4, last_delivery_date: '2026-08-30' })],
|
|
{ last_delivery_date: '2026-08-30' },
|
|
),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
|
|
// mode remaining: all 10, of which only 4 were delivered.
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: { due_date: '2026-09-30' } })
|
|
|
|
expect(result.ok).toBe(true)
|
|
const buildArg = mockBuildInvoiceWriteData.mock.calls[0][0] as { input: { delivery_date: unknown; items: { quantity: number }[] } }
|
|
expect(buildArg.input.delivery_date).toBeNull()
|
|
expect(buildArg.input.items[0].quantity).toBe(10)
|
|
})
|
|
|
|
it('refuses with SALES_ORDER_CUSTOMER_VAT_CHANGED when the customer type differs from the order snapshot', async () => {
|
|
enqueue({
|
|
data: orderWith([makeSalesOrderItem()], {
|
|
customer_type_snapshot: 'swedish_business',
|
|
customer_vat_validated_snapshot: false,
|
|
}),
|
|
})
|
|
enqueue({ data: [] })
|
|
// Since validated as an EU business: the frozen 25 % line would pass the
|
|
// permitted-set gate but is no longer what the customer should be charged.
|
|
enqueue({
|
|
data: makeOrderCustomer({ customer_type: 'eu_business', vat_number: 'DE123456789', vat_number_validated: true }),
|
|
})
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'SALES_ORDER_CUSTOMER_VAT_CHANGED',
|
|
details: {
|
|
snapshot: { customer_type: 'swedish_business', vat_number_validated: false },
|
|
current: { customer_type: 'eu_business', vat_number_validated: true },
|
|
},
|
|
})
|
|
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
|
|
expect(findCall('invoices', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('refuses when only the VAT-number validation flag changed since the snapshot', async () => {
|
|
enqueue({
|
|
data: orderWith([makeSalesOrderItem()], {
|
|
customer_type_snapshot: 'eu_business',
|
|
customer_vat_validated_snapshot: false,
|
|
}),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer({ customer_type: 'eu_business', vat_number_validated: true }) })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_CUSTOMER_VAT_CHANGED' })
|
|
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('treats a null validation snapshot as false and passes when the customer still matches', async () => {
|
|
enqueue({
|
|
data: orderWith([makeSalesOrderItem()], {
|
|
customer_type_snapshot: 'swedish_business',
|
|
customer_vat_validated_snapshot: null,
|
|
}),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer({ customer_type: 'swedish_business', vat_number_validated: null as unknown as boolean }) })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockBuildInvoiceWriteData).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('passes without a snapshot check when the order carries no customer_type_snapshot (pre-snapshot orders)', async () => {
|
|
enqueue({
|
|
data: orderWith([makeSalesOrderItem()], { customer_type_snapshot: null, customer_vat_validated_snapshot: null }),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer({ customer_type: 'eu_business', vat_number_validated: true }) })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockBuildInvoiceWriteData).toHaveBeenCalledTimes(1)
|
|
expect(findCall('invoices', 'insert')).toBeDefined()
|
|
})
|
|
|
|
it('propagates a builder domain failure without inserting', async () => {
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
mockBuildInvoiceWriteData.mockResolvedValue({
|
|
ok: false,
|
|
code: 'INVOICE_CREATE_VAT_RULE_VIOLATION',
|
|
details: { attemptedRate: 25 },
|
|
})
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'INVOICE_CREATE_VAT_RULE_VIOLATION', details: { attemptedRate: 25 } })
|
|
expect(findCall('invoices', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('deletes the draft and maps the over-invoice trigger when the line insert fails', async () => {
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({
|
|
data: null,
|
|
error: { message: 'SALES_ORDER_OVER_INVOICED: line d1000000 would exceed ordered quantity', code: 'P0001' },
|
|
})
|
|
enqueue({ data: null }) // invoice_items delete
|
|
enqueue({ data: null }) // invoices delete
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'SALES_ORDER_OVER_INVOICED' })
|
|
expect(findCall('invoice_items', 'delete')).toBeDefined()
|
|
expect(findCall('invoice_items', 'eq')).toEqual(['invoice_id', IDS.invoice])
|
|
expect(findCall('invoices', 'delete')).toBeDefined()
|
|
expect(findCall('invoices', 'eq')).toEqual(['id', IDS.invoice])
|
|
})
|
|
|
|
it('deletes the draft and returns the raw DB error when the line insert fails for another reason', async () => {
|
|
enqueue({ data: orderWith() })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: makeOrderCustomer() })
|
|
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
|
|
enqueue({ data: null, error: { message: 'null value in column "description"', code: '23502' } })
|
|
enqueue({ data: null })
|
|
enqueue({ data: null })
|
|
|
|
const result = await createInvoiceFromSalesOrder(sb, { ...params, input: {} })
|
|
|
|
expect(result.ok).toBe(false)
|
|
expect('dbError' in result && result.dbError).toMatchObject({ code: '23502' })
|
|
expect(findCall('invoices', 'delete')).toBeDefined()
|
|
})
|
|
})
|