* 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>
499 lines
23 KiB
TypeScript
499 lines
23 KiB
TypeScript
/**
|
|
* Offert (quote) surface on the MCP tools.
|
|
*
|
|
* A quote is document_type 'quote': numbered from its own OF-series at
|
|
* approval (generate_quote_number, never generate_invoice_number), never
|
|
* books, carries valid_until, and is decided with gnubok_set_quote_status.
|
|
* gnubok_convert_invoice creates the faktura from an open or accepted quote.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { tools } from '../server'
|
|
|
|
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
|
|
const setQuoteStatus = tools.find((t) => t.name === 'gnubok_set_quote_status')!
|
|
const convertInvoice = tools.find((t) => t.name === 'gnubok_convert_invoice')!
|
|
const listInvoices = tools.find((t) => t.name === 'gnubok_list_invoices')!
|
|
|
|
const CUSTOMER = {
|
|
id: 'cust-1',
|
|
name: 'Testbrand AB',
|
|
customer_type: 'swedish_business',
|
|
vat_number_validated: false,
|
|
default_payment_terms: 30,
|
|
}
|
|
|
|
const ITEMS = [{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 }]
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('gnubok_create_invoice: quotes', () => {
|
|
it('declares document_type quote and valid_until on the input schema', () => {
|
|
const props = (createInvoice.inputSchema as { properties: Record<string, { enum?: string[] }> }).properties
|
|
expect(props.document_type.enum).toEqual(['invoice', 'quote'])
|
|
expect(props.valid_until).toBeDefined()
|
|
})
|
|
|
|
it('requires valid_until for a quote', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
await expect(
|
|
createInvoice.execute(
|
|
{ customer_id: 'cust-1', document_type: 'quote', items: ITEMS },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
|
})
|
|
|
|
it('refuses valid_until on a plain invoice', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
await expect(
|
|
createInvoice.execute(
|
|
{ customer_id: 'cust-1', valid_until: '2026-12-31', items: ITEMS },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
|
})
|
|
|
|
it('stages a quote with due_date mirroring valid_until and no F-series preview', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: CUSTOMER, error: null }) // customers fetch
|
|
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
|
|
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
|
|
enqueue({ data: { id: 'op-quote' }, error: null }) // pending_operations insert
|
|
|
|
const result = (await createInvoice.execute(
|
|
{
|
|
customer_id: 'cust-1',
|
|
document_type: 'quote',
|
|
valid_until: '2026-12-31',
|
|
invoice_date: '2026-09-02',
|
|
items: ITEMS,
|
|
},
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { staged: boolean; message: string; preview: Record<string, unknown> }
|
|
|
|
expect(result.staged).toBe(true)
|
|
expect(result.preview.document_type).toBe('quote')
|
|
expect(result.preview.valid_until).toBe('2026-12-31')
|
|
expect(result.preview.due_date).toBe('2026-12-31')
|
|
expect(result.preview.total).toBe(2500)
|
|
expect(String(result.preview.invoice_number)).not.toContain('F-PREVIEW')
|
|
expect(result.preview.invoice_number).toBe('Offert OF-preview')
|
|
|
|
// The staged params carry what the executor needs to allocate OF-nnn.
|
|
const insertArgs = findCall('pending_operations', 'insert')
|
|
expect(insertArgs).toBeDefined()
|
|
const row = insertArgs![0] as { operation_type: string; title: string; params: Record<string, unknown> }
|
|
expect(row.operation_type).toBe('create_invoice')
|
|
expect(row.title).toContain('Ny offert')
|
|
expect(row.params).toMatchObject({ document_type: 'quote', valid_until: '2026-12-31', due_date: '2026-12-31' })
|
|
})
|
|
})
|
|
|
|
describe('gnubok_set_quote_status', () => {
|
|
it('is a direct invoices:write tool with a strict schema', () => {
|
|
expect(setQuoteStatus).toBeDefined()
|
|
expect(setQuoteStatus.inputSchema.additionalProperties).toBe(false)
|
|
expect(setQuoteStatus.annotations?.readOnlyHint).toBe(false)
|
|
})
|
|
|
|
it('records the decision and returns the effective status', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null }, error: null })
|
|
enqueue({ data: null, error: null }) // converted_from_id lookup: nothing yet
|
|
enqueue({
|
|
data: {
|
|
id: 'q-1',
|
|
invoice_number: 'OF-003',
|
|
document_type: 'quote',
|
|
status: 'sent',
|
|
quote_status: 'accepted',
|
|
quote_decided_at: '2026-09-02T10:00:00.000Z',
|
|
valid_until: '2099-12-31',
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
const result = (await setQuoteStatus.execute(
|
|
{ invoice_id: 'q-1', status: 'accepted' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as Record<string, unknown>
|
|
|
|
expect(result).toMatchObject({
|
|
invoice_id: 'q-1',
|
|
invoice_number: 'OF-003',
|
|
document_type: 'quote',
|
|
quote_status: 'accepted',
|
|
effective_quote_status: 'accepted',
|
|
valid_until: '2099-12-31',
|
|
})
|
|
expect(findCall('invoices', 'update')?.[0]).toMatchObject({ quote_status: 'accepted' })
|
|
})
|
|
|
|
it('reports expired for an open quote past valid_until', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'declined', quote_decided_at: 'x' }, error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2020-01-01' },
|
|
error: null,
|
|
})
|
|
|
|
const result = (await setQuoteStatus.execute(
|
|
{ invoice_id: 'q-1', status: 'open' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as Record<string, unknown>
|
|
|
|
expect(result.quote_status).toBe('open')
|
|
expect(result.effective_quote_status).toBe('expired')
|
|
})
|
|
|
|
it('refuses a document that is not a quote with INVOICE_NOT_A_QUOTE', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'i-1', document_type: 'invoice', status: 'sent', quote_status: null, quote_decided_at: null }, error: null })
|
|
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'i-1', status: 'accepted' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_NOT_A_QUOTE' })
|
|
expect(findCall('invoices', 'update')).toBeUndefined()
|
|
})
|
|
|
|
it('locks the decision once the quote has been invoiced', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'accepted', quote_decided_at: 'x' }, error: null })
|
|
enqueue({ data: { id: 'inv-9' }, error: null }) // active converted invoice exists
|
|
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'declined' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_INVOICED' })
|
|
})
|
|
|
|
it('rejects an unknown status before touching the database', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'expired' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('gnubok_convert_invoice: quotes', () => {
|
|
it('fails on a declined quote with INVOICE_CONVERT_QUOTE_DECLINED', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'declined', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_CONVERT_QUOTE_DECLINED' })
|
|
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('fails on an already-invoiced quote with INVOICE_QUOTE_ALREADY_INVOICED', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
enqueue({ data: { id: 'inv-9' }, error: null })
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_INVOICED' })
|
|
})
|
|
|
|
it('still refuses a regular invoice with INVOICE_CONVERT_NOT_CONVERTIBLE', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'i-1', invoice_number: '2026-0001', document_type: 'invoice', status: 'sent', quote_status: null, total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'i-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_CONVERT_NOT_CONVERTIBLE' })
|
|
})
|
|
|
|
it('stages the conversion of an open quote with an offert summary', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'open', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // converted_from_id lookup
|
|
enqueue({ data: null, count: 0, error: null }) // live sales_orders count
|
|
enqueue({ data: { id: 'op-convert' }, error: null }) // pending_operations insert
|
|
|
|
const result = (await convertInvoice.execute(
|
|
{ invoice_id: 'q-1' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { staged: boolean; message: string; preview: Record<string, unknown> }
|
|
|
|
expect(result.staged).toBe(true)
|
|
const row = findCall('pending_operations', 'insert')![0] as { title: string; params: Record<string, unknown> }
|
|
expect(row.title).toContain('Konvertera offert → faktura')
|
|
expect(row.title).toContain('OF-003')
|
|
expect(row.params).toEqual({ invoice_id: 'q-1' })
|
|
expect(result.preview.source_document_type).toBe('quote')
|
|
expect(result.preview.target).toBe('invoice')
|
|
expect(String(result.preview.will)).toContain('accepted')
|
|
})
|
|
|
|
it('fails on a quote that already has a live kundorder with INVOICE_QUOTE_ALREADY_ORDERED', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // no converted invoice
|
|
enqueue({ data: null, count: 1, error: null }) // one live sales order
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_ORDERED' })
|
|
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('gnubok_convert_invoice: target order (offert -> kundorder -> faktura)', () => {
|
|
const openQuote = { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'open', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } }
|
|
|
|
it('declares target as an enum of invoice and order', () => {
|
|
const props = convertInvoice.inputSchema.properties as Record<string, { enum?: string[] }>
|
|
expect(props.target.enum).toEqual(['invoice', 'order'])
|
|
expect(convertInvoice.inputSchema.required).toEqual(['invoice_id'])
|
|
})
|
|
|
|
it('rejects an unknown target before touching the database', async () => {
|
|
const { supabase, findCall } = createQueuedMockSupabase()
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1', target: 'delivery_note' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/target must be invoice or order/)
|
|
expect(findCall('invoices', 'select')).toBeUndefined()
|
|
})
|
|
|
|
it('stages a quote -> kundorder conversion with target in the params and the order tools as next step', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: openQuote, error: null })
|
|
enqueue({ data: null, count: 0, error: null }) // no live sales order
|
|
enqueue({ data: null, error: null }) // no converted invoice
|
|
enqueue({ data: { id: 'op-convert' }, error: null })
|
|
|
|
const result = (await convertInvoice.execute(
|
|
{ invoice_id: 'q-1', target: 'order' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { staged: boolean; preview: Record<string, unknown>; next?: { tool: string } }
|
|
|
|
expect(result.staged).toBe(true)
|
|
const row = findCall('pending_operations', 'insert')![0] as { title: string; params: Record<string, unknown>; operation_type: string }
|
|
expect(row.operation_type).toBe('convert_invoice')
|
|
expect(row.params).toEqual({ invoice_id: 'q-1', target: 'order' })
|
|
expect(row.title).toContain('Konvertera offert → kundorder')
|
|
expect(row.title).toContain('OF-003')
|
|
expect(result.preview.target).toBe('order')
|
|
expect(String(result.preview.will)).toContain('kundorder')
|
|
expect(String(result.preview.will)).toContain('accepted')
|
|
expect(result.next?.tool).toBe('gnubok_transition_sales_order')
|
|
})
|
|
|
|
it('stages a proforma -> kundorder conversion that cancels the proforma', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...openQuote, id: 'p-1', invoice_number: null, document_type: 'proforma', quote_status: null }, error: null })
|
|
enqueue({ data: null, count: 0, error: null })
|
|
enqueue({ data: { id: 'op-convert' }, error: null })
|
|
|
|
const result = (await convertInvoice.execute(
|
|
{ invoice_id: 'p-1', target: 'order' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { staged: boolean; preview: Record<string, unknown> }
|
|
|
|
expect(result.staged).toBe(true)
|
|
const row = findCall('pending_operations', 'insert')![0] as { title: string }
|
|
expect(row.title).toContain('Konvertera proforma → kundorder')
|
|
expect(String(result.preview.will)).toContain('cancel proforma')
|
|
})
|
|
|
|
it('refuses a declined quote with INVOICE_CONVERT_QUOTE_DECLINED', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...openQuote, quote_status: 'declined' }, error: null })
|
|
enqueue({ data: null, count: 0, error: null })
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1', target: 'order' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_CONVERT_QUOTE_DECLINED' })
|
|
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('refuses a quote that already has a live order with SALES_ORDER_SOURCE_ALREADY_CONVERTED', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...openQuote, quote_status: 'accepted' }, error: null })
|
|
enqueue({ data: null, count: 1, error: null })
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1', target: 'order' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'SALES_ORDER_SOURCE_ALREADY_CONVERTED' })
|
|
})
|
|
|
|
it('refuses a quote that already has a live invoice with INVOICE_QUOTE_ALREADY_INVOICED', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...openQuote, quote_status: 'accepted' }, error: null })
|
|
enqueue({ data: null, count: 0, error: null })
|
|
enqueue({ data: { id: 'inv-9' }, error: null })
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'q-1', target: 'order' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_INVOICED' })
|
|
})
|
|
|
|
it('refuses a regular invoice with SALES_ORDER_SOURCE_NOT_PROFORMA', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...openQuote, id: 'i-1', document_type: 'invoice', quote_status: null }, error: null })
|
|
|
|
await expect(
|
|
convertInvoice.execute({ invoice_id: 'i-1', target: 'order' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'SALES_ORDER_SOURCE_NOT_PROFORMA' })
|
|
})
|
|
})
|
|
|
|
describe('gnubok_list_invoices: quote filters', () => {
|
|
it('filters expired quotes as open past valid_until and exposes the derived status', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [
|
|
{ id: 'q-1', invoice_number: 'OF-001', status: 'sent', document_type: 'quote', quote_status: 'open', valid_until: '2020-01-01', total: 100, currency: 'SEK', customers: { name: 'Testbrand AB' } },
|
|
],
|
|
error: null,
|
|
count: 1,
|
|
})
|
|
|
|
const result = (await listInvoices.execute(
|
|
{ quote_status: 'expired' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { invoices: Array<Record<string, unknown>> }
|
|
|
|
expect(result.invoices[0]).toMatchObject({ document_type: 'quote', valid_until: '2020-01-01', quote_status: 'expired' })
|
|
const eqCalls = findCalls('invoices', 'eq')
|
|
expect(eqCalls).toContainEqual(['document_type', 'quote'])
|
|
expect(eqCalls).toContainEqual(['quote_status', 'open'])
|
|
expect(findCalls('invoices', 'lt')[0]?.[0]).toBe('valid_until')
|
|
})
|
|
|
|
it('returns quote_status null for a regular invoice', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [{ id: 'i-1', invoice_number: '2026-0001', status: 'sent', document_type: 'invoice', quote_status: null, valid_until: null, total: 100, currency: 'SEK', customers: null }],
|
|
error: null,
|
|
count: 1,
|
|
})
|
|
|
|
const result = (await listInvoices.execute({}, 'company-1', 'user-1', supabase as never)) as {
|
|
invoices: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(result.invoices[0].quote_status).toBeNull()
|
|
expect(result.invoices[0].valid_until).toBeNull()
|
|
})
|
|
|
|
describe('quotes are never claims on the MCP payment tools', () => {
|
|
const markPaid = tools.find((t) => t.name === 'gnubok_mark_invoice_as_paid')!
|
|
const matchTx = tools.find((t) => t.name === 'gnubok_match_transaction_to_invoice')!
|
|
|
|
it('gnubok_mark_invoice_as_paid refuses a sent quote with INVOICE_QUOTE_NOT_PAYABLE', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
markPaid.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_NOT_PAYABLE' })
|
|
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('gnubok_match_transaction_to_invoice refuses a sent quote with MATCH_INVOICE_NOT_INVOICE_TYPE', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'tx-1', description: 'Inbetalning', merchant_name: null, amount: 2500, currency: 'SEK', date: '2026-06-10', invoice_id: null },
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, remaining_amount: 0, currency: 'SEK', customer: { name: 'Testbrand AB' } },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
matchTx.execute({ transaction_id: 'tx-1', invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'MATCH_INVOICE_NOT_INVOICE_TYPE' })
|
|
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('gnubok_set_quote_status: concurrency and expiry', () => {
|
|
it('reports INVOICE_QUOTE_CHANGED_CONCURRENTLY when the compare-and-set update hits 0 rows', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-12-31' }, error: null })
|
|
enqueue({ data: null, error: null }) // no converted invoice yet
|
|
enqueue({ data: null, error: null }) // update matched 0 rows: converted in between
|
|
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'declined' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_CHANGED_CONCURRENTLY' })
|
|
})
|
|
|
|
it('maps the decision guard trigger to INVOICE_QUOTE_ALREADY_ORDERED when a live kundorder locks the quote', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'accepted', quote_decided_at: '2026-06-01T10:00:00Z', valid_until: '2026-12-31' }, error: null })
|
|
enqueue({ data: null, error: null }) // no converted invoice
|
|
enqueue({ data: null, error: { code: 'P0001', message: 'INVOICE_QUOTE_ALREADY_ORDERED: quote q-1 has a live kundorder' } })
|
|
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'declined' }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_ORDERED' })
|
|
})
|
|
|
|
it('writes a new valid_until with the decision and rejects a malformed one', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-01-31' }, error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-12-31' }, error: null })
|
|
|
|
await setQuoteStatus.execute({ invoice_id: 'q-1', status: 'open', valid_until: '2026-12-31' }, 'company-1', 'user-1', supabase as never)
|
|
const update = findCall('invoices', 'update')?.[0] as Record<string, unknown>
|
|
expect(update.valid_until).toBe('2026-12-31')
|
|
|
|
const second = createQueuedMockSupabase()
|
|
second.enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-01-31' }, error: null })
|
|
second.enqueue({ data: null, error: null })
|
|
await expect(
|
|
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'open', valid_until: 'next week' }, 'company-1', 'user-1', second.supabase as never),
|
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
|
})
|
|
})
|
|
})
|