feat(invoices): offert to kundorder, the missing step in offert, order, faktura (#2442)

* 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>
This commit is contained in:
Mattsson
2026-09-08 18:29:37 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 32721b9f61
commit 9782f80db0
30 changed files with 1419 additions and 168 deletions
@@ -1,11 +1,13 @@
/**
* POST /api/invoices/[id]/convert-to-order: proforma -> draft kundorder.
* POST /api/invoices/[id]/convert-to-order: proforma or offert -> draft
* kundorder.
*
* Queue order: invoices select (proforma + items), sales_orders head count
* (already converted?), then createSalesOrder (customers select,
* Queue order: invoices select (source + items), sales_orders head count
* (live order already?), for a quote the invoices converted_from_id lookup
* (live invoice already?), 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.
* update that marks the proforma cancelled or the quote accepted.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -308,6 +310,160 @@ describe('POST /api/invoices/[id]/convert-to-order', () => {
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
describe('offert (quote) sources', () => {
function makeQuote(overrides: Record<string, unknown> = {}) {
return makeProforma({
document_type: 'quote',
status: 'sent',
invoice_number: 'OF-003',
valid_until: '2026-06-30',
due_date: '2026-06-30',
quote_status: 'open',
quote_decided_at: null,
...overrides,
})
}
it('returns 409 INVOICE_CONVERT_QUOTE_DECLINED for a declined quote', async () => {
enqueue({ data: makeQuote({ quote_status: 'declined' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_CONVERT_QUOTE_DECLINED')
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('returns 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED when a live order already points at the quote', async () => {
enqueue({ data: makeQuote({ quote_status: 'accepted' }) })
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')
// A cancelled order frees the quote: the count excludes cancelled rows.
expect(findCalls('sales_orders', 'neq')).toContainEqual(['status', 'cancelled'])
})
it('returns 409 INVOICE_QUOTE_ALREADY_INVOICED when a live invoice was converted from the quote', async () => {
enqueue({ data: makeQuote({ quote_status: 'accepted' }) })
enqueue({ data: null, count: 0 })
enqueue({ data: { id: 'f1000000-0000-4000-8000-000000000009' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
expect(findCalls('invoices', 'eq')).toContainEqual(['converted_from_id', IDS.invoice])
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('creates a draft order from an open quote, marks the quote accepted (it stays) and answers 201', async () => {
enqueue({ data: makeQuote() })
enqueue({ data: null, count: 0 }) // no live order
enqueue({ data: null }) // no live converted invoice
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 }] }) // quote 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.source_invoice_id).toBe(IDS.invoice)
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
customer_id: IDS.customer,
source_invoice_id: IDS.invoice,
total: 1250,
})
// The quote is the customer's accepted agreement: it flips to accepted
// with a compare-and-set on the decision that was read, never cancelled.
const update = findCall('invoices', 'update')![0] as Record<string, unknown>
expect(update.quote_status).toBe('accepted')
expect(typeof update.quote_decided_at).toBe('string')
expect(update.status).toBeUndefined()
expect(findCalls('invoices', 'eq')).toContainEqual(['quote_status', 'open'])
expect(findCall('invoices', 'neq')).toEqual(['status', 'cancelled'])
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('keeps the original decision timestamp when an accepted quote becomes an order', async () => {
enqueue({ data: makeQuote({ quote_status: 'accepted', quote_decided_at: '2026-06-01T10:00:00Z' }) })
enqueue({ data: null, count: 0 })
enqueue({ data: null })
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)
expect(findCall('invoices', 'update')![0]).toEqual({
quote_status: 'accepted',
quote_decided_at: '2026-06-01T10:00:00Z',
})
expect(findCalls('invoices', 'eq')).toContainEqual(['quote_status', 'accepted'])
})
it('maps the one-live-order index violation on the header insert to 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED', async () => {
enqueue({ data: makeQuote({ quote_status: 'accepted' }) })
enqueue({ data: null, count: 0 }) // pre-check passed (race)
enqueue({ data: null })
enqueue({ data: makeOrderCustomer() })
enqueue({
data: null,
error: { code: '23505', message: 'duplicate key value violates unique constraint "uq_sales_orders_one_live_per_source"' },
})
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('invoices', 'update')).toBeUndefined()
})
it('maps the source guard trigger on the header insert to 409 INVOICE_QUOTE_ALREADY_INVOICED', async () => {
enqueue({ data: makeQuote({ quote_status: 'accepted' }) })
enqueue({ data: null, count: 0 })
enqueue({ data: null }) // pre-check passed (race)
enqueue({ data: makeOrderCustomer() })
enqueue({
data: null,
error: { code: 'P0001', message: `INVOICE_QUOTE_ALREADY_INVOICED: quote ${IDS.invoice} has a live converted invoice` },
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
expect(findCall('invoices', 'update')).toBeUndefined()
})
it('removes the fresh order and answers 409 when the quote was decided or converted concurrently', async () => {
enqueue({ data: makeQuote() })
enqueue({ data: null, count: 0 })
enqueue({ data: null })
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()
})
})
it('removes the fresh order and answers 409 when the proforma was converted concurrently', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 0 })
@@ -1,21 +1,21 @@
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 { convertToSalesOrder } from '@/lib/sales-orders/convert-to-sales-order'
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.
* POST /api/invoices/[id]/convert-to-order: proforma or offert -> draft
* kundorder. Sibling of /convert (-> invoice): copies the lines into a new
* order; the proforma is cancelled, the quote stays as accepted.
*/
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 })
const result = await convertToSalesOrder(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 })
},
@@ -284,11 +284,34 @@ describe('POST /api/invoices/[id]/convert', () => {
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('refuses while a live kundorder was created from the quote (invoice from the order instead)', async () => {
// 1. fetch quote
enqueue({ data: baseQuote, error: null })
// 2. no converted invoice
enqueue({ data: null, error: null })
// 3. one live sales order with source_invoice_id = quote
enqueue({ data: null, count: 1, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_ORDERED')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['source_invoice_id', 'q-1'])
expect(findCall('invoices', 'insert')).toBeUndefined()
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('creates the invoice with a due date from the customer terms, marks the quote accepted and keeps it', async () => {
// 1. fetch quote
enqueue({ data: baseQuote, error: null })
// 2. no existing conversion
enqueue({ data: null, error: null })
// 2b. no live sales order from the quote
enqueue({ data: null, count: 0, error: null })
// 3. insert invoice
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null })
// 4. insert items
@@ -351,6 +374,7 @@ describe('POST /api/invoices/[id]/convert', () => {
mockFetchExchangeRate.mockResolvedValue({ rate: 11.45, date: '2026-07-28' })
enqueue({ data: eurQuote, error: null }) // fetch quote
enqueue({ data: null, error: null }) // no existing conversion
enqueue({ data: null, count: 0, error: null }) // no live sales order
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null })
enqueue({ data: null, error: null }) // items
enqueue({ data: [{ id: 'q-eur' }], error: null }) // quote -> accepted
@@ -377,6 +401,7 @@ describe('POST /api/invoices/[id]/convert', () => {
mockFetchExchangeRate.mockResolvedValue(null)
enqueue({ data: eurQuote, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, count: 0, error: null }) // no live sales order
const response = await POST(
createMockRequest('/api/invoices/q-eur/convert', { method: 'POST' }),
@@ -393,6 +418,7 @@ describe('POST /api/invoices/[id]/convert', () => {
it('maps the one-live-conversion unique index violation to INVOICE_QUOTE_ALREADY_INVOICED', async () => {
enqueue({ data: { ...baseProforma, id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', customer: { default_payment_terms: 30 } }, error: null })
enqueue({ data: null, error: null }) // existence check passed (race)
enqueue({ data: null, count: 0, error: null }) // no live sales order
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_invoices_one_live_conversion"' } })
const response = await POST(
@@ -406,6 +432,23 @@ describe('POST /api/invoices/[id]/convert', () => {
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('maps the converted-source guard trigger (a kundorder went live meanwhile) to INVOICE_QUOTE_ALREADY_ORDERED', async () => {
enqueue({ data: { ...baseProforma, id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'accepted', customer: { default_payment_terms: 30 } }, error: null })
enqueue({ data: null, error: null }) // no converted invoice
enqueue({ data: null, count: 0, error: null }) // no live order at pre-check time (race)
enqueue({ data: null, error: { code: 'P0001', message: 'INVOICE_QUOTE_ALREADY_ORDERED: quote q-1 has a live kundorder' } })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_ORDERED')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('removes the orphan invoice and refuses when the proforma was cancelled concurrently (0-row compare-and-set)', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
@@ -108,6 +108,19 @@ describe('POST /api/invoices/[id]/quote-status', () => {
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
})
it('maps the decision guard trigger to 409 INVOICE_QUOTE_ALREADY_ORDERED when a live kundorder locks the quote', async () => {
enqueue({ data: { ...quoteRow, quote_status: 'accepted' }, 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' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ status: 'declined' }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_ORDERED')
})
it('records an acceptance with a decided_at timestamp', async () => {
enqueue({ data: quoteRow, error: null })
enqueue({ data: null, error: null })
@@ -99,6 +99,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
return errorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', log, { requestId })
}
// Same guard: a live kundorder created from the quote locks it too.
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_ORDERED')) {
return errorResponseFromCode('INVOICE_QUOTE_ALREADY_ORDERED', log, { requestId })
}
log.error('quote status update failed', updateError, { quoteId: id })
return errorResponse(updateError, log, { requestId })
}
@@ -229,6 +229,28 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/quote-status', () => {
expect(body.error.details.invoice_number).toBe('2026-0042')
})
it('returns 409 INVOICE_QUOTE_ALREADY_ORDERED when the decision guard refuses because a live kundorder exists', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: [
{ data: { ...OPEN_QUOTE, quote_status: 'accepted' }, error: null },
{ data: null, error: null }, // no converted invoice
{ data: null, error: { code: 'P0001', message: `INVOICE_QUOTE_ALREADY_ORDERED: quote ${QUOTE_ID} has a live kundorder` } },
],
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'declined' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_ORDERED')
})
it('records the decision and returns the effective status', async () => {
const calls: RecordedCall[] = []
const decidedAt = '2026-09-02T09:14:33.000Z'
@@ -222,6 +222,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (updateError) {
// trg_invoices_quote_decision_guard: a conversion landed between the
// read above and this write, so the decision is locked in accepted.
if ((updateError as { message?: string }).message?.includes('INVOICE_QUOTE_ALREADY_ORDERED')) {
return v1ErrorResponseFromCode('INVOICE_QUOTE_ALREADY_ORDERED', ctx.log, {
requestId: ctx.requestId,
})
}
if ((updateError as { message?: string }).message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
return v1ErrorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', ctx.log, {
requestId: ctx.requestId,