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
parent 32721b9f61
commit 9782f80db0
30 changed files with 1419 additions and 168 deletions
+2
View File
@@ -1672,5 +1672,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale.
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS 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. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
+56 -7
View File
@@ -235,6 +235,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Offert: the invoice created from this quote (converted_from_id points
// back here), and the accept/decline round trip.
const [quoteInvoice, setQuoteInvoice] = useState<Invoice | null>(null)
// Offert -> kundorder: the live order created from this quote, if any. Its
// presence locks the decision and moves invoicing to the order.
const [quoteOrder, setQuoteOrder] = useState<{ id: string; order_number: string | null } | null>(null)
const [showExpiredOrderDialog, setShowExpiredOrderDialog] = useState(false)
const [isDeciding, setIsDeciding] = useState(false)
const [showExpiredAcceptDialog, setShowExpiredAcceptDialog] = useState(false)
const [showExpiredConvertDialog, setShowExpiredConvertDialog] = useState(false)
@@ -582,7 +586,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.order('created_at', { ascending: false })
.limit(1)
: Promise.resolve(null),
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes, invoicedRes]) => {
data.document_type === 'quote'
? supabase
.from('sales_orders')
.select('id, order_number')
.eq('source_invoice_id', id)
.neq('status', 'cancelled')
.order('created_at', { ascending: false })
.limit(1)
: Promise.resolve(null),
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes, invoicedRes, orderedRes]) => {
// Deferred writes need the same guard: they land after first paint
// and would otherwise attach the previous invoice's related documents
// to the one the pager has since navigated to.
@@ -596,6 +609,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setConvertedFromInvoice(convertedRes.data as Invoice)
}
setQuoteInvoice((invoicedRes?.data?.[0] as Invoice | undefined) ?? null)
setQuoteOrder(
(orderedRes?.data?.[0] as { id: string; order_number: string | null } | undefined) ?? null,
)
})
}
@@ -753,8 +769,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsConverting(false)
}
// Proforma -> draft kundorder (sibling of convertToInvoice). The proforma is
// cancelled by the service; the user lands on the new order.
/** "Skapa order" on an expired open quote confirms the lapse first, like
* startQuoteConvert; an accepted quote past valid_until converts directly. */
function startQuoteOrder() {
if (!invoice) return
if (isQuoteExpired(invoice)) {
setShowExpiredOrderDialog(true)
return
}
void convertToOrder()
}
// Proforma or offert -> draft kundorder (sibling of convertToInvoice). The
// service cancels the proforma or marks the quote accepted; the user lands
// on the new order.
async function convertToOrder() {
if (!invoice) return
setIsCreatingOrder(true)
@@ -1467,7 +1495,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Offert: the effective status (expired is derived, never stored) and what
// can still happen to it. Once an invoice exists the decision is final.
const quoteStatus = isQuote ? effectiveQuoteStatus(invoice) : null
const canDecideQuote = isQuote && invoice.status !== 'cancelled' && !quoteInvoice
const canDecideQuote = isQuote && invoice.status !== 'cancelled' && !quoteInvoice && !quoteOrder
const canConvertQuote = canDecideQuote && quoteStatus !== 'declined'
// #1693: only a fully paid faktura has a betalningsbekräftelse to offer.
const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice)
@@ -1830,11 +1858,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('quote_create_invoice')}
</Button>
)}
{isProforma && invoice.status !== 'cancelled' && (
{((isProforma && invoice.status !== 'cancelled') || canConvertQuote) && (
<Button
variant="outline"
onClick={convertToOrder}
disabled={isCreatingOrder || !canWrite}
onClick={isQuote ? startQuoteOrder : convertToOrder}
disabled={isCreatingOrder || isConverting || isDeciding || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isCreatingOrder ? (
@@ -2286,6 +2314,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Link>
</DefRow>
)}
{isQuote && quoteOrder && (
<DefRow label={t('def_sales_order')}>
<Link href={`/sales-orders/${quoteOrder.id}`} className="hover:underline">
{quoteOrder.order_number ?? t('open_sales_order')}
</Link>
</DefRow>
)}
{invoice.sales_order_id && (
<DefRow label={t('def_sales_order')}>
<Link href={`/sales-orders/${invoice.sales_order_id}`} className="hover:underline">
@@ -2901,6 +2936,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}}
/>
<ConfirmDialog
open={showExpiredOrderDialog}
onOpenChange={setShowExpiredOrderDialog}
title={t('quote_expired_order_title')}
description={t('quote_expired_order_description', {
date: formatDate(invoice.valid_until ?? invoice.due_date),
})}
confirmLabel={t('create_order')}
onConfirm={async () => {
setShowExpiredOrderDialog(false)
await convertToOrder()
}}
/>
<ConfirmDialog
open={showExpiredAcceptDialog}
onOpenChange={setShowExpiredAcceptDialog}
+4 -4
View File
@@ -900,7 +900,7 @@ export default function PendingOperationsPage() {
showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked
const isSelected = selectedIds.has(op.id)
const isAgent = op.actor_type && op.actor_type !== 'user'
const warningSentence = singleActionWarning(op.operation_type)
const warningSentence = singleActionWarning(op.operation_type, op.params)
const showHighRiskWarning =
op.risk_level === 'high' && warningSentence && op.status === 'pending'
@@ -1148,10 +1148,10 @@ export default function PendingOperationsPage() {
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-snug">{detailOp.params.notes}</p>
</div>
)}
{detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type) && (
{detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type, detailOp.params) && (
<div className="rounded-lg border border-border bg-secondary/25 px-3 py-2">
<p className="text-xs leading-snug text-muted-foreground">
{singleActionWarning(detailOp.operation_type)}
{singleActionWarning(detailOp.operation_type, detailOp.params)}
</p>
</div>
)}
@@ -1229,7 +1229,7 @@ export default function PendingOperationsPage() {
open={showCommitDialog}
onOpenChange={setShowCommitDialog}
title={selectedOp?.title || t('approve_operation_title')}
warningText={selectedOp ? singleActionWarning(selectedOp.operation_type) : ''}
warningText={selectedOp ? singleActionWarning(selectedOp.operation_type, selectedOp.params) : ''}
confirmLabel={t('approve')}
isSubmitting={isCommitting}
onConfirm={handleCommit}
+18 -1
View File
@@ -97,12 +97,27 @@ export default function SalesOrderDetailPage({ params }: { params: Promise<{ id:
// Invoices created from this order (GET /api/invoices?sales_order_id=).
const [invoices, setInvoices] = useState<Invoice[]>([])
// The source document (proforma or offert) the order was created from:
// only its type and number, for the "Skapad från" row.
const [sourceDoc, setSourceDoc] = useState<{ document_type: string; invoice_number: string | null } | null>(null)
const fetchOrder = useCallback(async () => {
try {
const res = await fetch(`/api/sales-orders/${id}`)
if (!res.ok) throw new Error('load failed')
const { data } = await res.json()
setOrder(data as SalesOrder)
const sourceId = (data as SalesOrder).source_invoice_id
if (sourceId) {
const sourceRes = await fetch(`/api/invoices/${sourceId}`).catch(() => null)
const sourceJson = sourceRes?.ok ? await sourceRes.json().catch(() => null) : null
const source = (sourceJson?.data ?? sourceJson) as { document_type?: string; invoice_number?: string | null } | null
setSourceDoc(
source?.document_type ? { document_type: source.document_type, invoice_number: source.invoice_number ?? null } : null,
)
} else {
setSourceDoc(null)
}
} catch {
toast({ title: t('load_failed_title'), variant: 'destructive' })
router.push('/sales-orders')
@@ -316,7 +331,9 @@ export default function SalesOrderDetailPage({ params }: { params: Promise<{ id:
{order.source_invoice_id && (
<DefRow label={t('def_source_invoice')}>
<Link href={`/invoices/${order.source_invoice_id}`} className="hover:underline">
{t('source_proforma')}
{sourceDoc?.document_type === 'quote'
? t('source_quote', { number: sourceDoc.invoice_number ?? '' })
: t('source_proforma')}
</Link>
</DefRow>
)}
@@ -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,
@@ -0,0 +1,27 @@
/**
* singleActionWarning: the consequence sentence the approver consents to
* must describe what the commit executor will actually do. convert_invoice
* is the one type whose outcome depends on its params (target 'order').
*/
import { describe, it, expect } from 'vitest'
import { singleActionWarning, singleActionWarnings } from '../vocabulary'
describe('singleActionWarning', () => {
it('promises a faktura with F-number for convert_invoice without a target (and with target invoice)', () => {
expect(singleActionWarning('convert_invoice')).toContain('F-nummer')
expect(singleActionWarning('convert_invoice', { invoice_id: 'q-1' })).toContain('F-nummer')
expect(singleActionWarning('convert_invoice', { invoice_id: 'q-1', target: 'invoice' })).toContain('F-nummer')
})
it('describes a draft kundorder and no booking for convert_invoice with target order', () => {
const sentence = singleActionWarning('convert_invoice', { invoice_id: 'q-1', target: 'order' })
expect(sentence).toContain('kundorder')
expect(sentence).toContain('Ingen faktura')
expect(sentence).not.toContain('F-nummer')
})
it('ignores params for every other operation type', () => {
expect(singleActionWarning('credit_invoice', { target: 'order' })).toBe(singleActionWarnings.credit_invoice)
expect(singleActionWarning('unknown_type', { target: 'order' })).toBe('')
})
})
+12 -2
View File
@@ -130,13 +130,23 @@ export const singleActionWarnings: Record<string, string> = {
delete_draft_invoice: 'Genom att klicka godkänn så tas utkastet bort: onumrerade utkast raderas permanent, numrerade makuleras med bevarat fakturanummer.',
credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.',
approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.',
convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.',
convert_invoice: 'Genom att klicka godkänn så konverteras proforman eller offerten till en riktig faktura med F-nummer.',
import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.',
explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).',
post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning: en verifikation per tillgång.',
}
export function singleActionWarning(operationType: string): string {
/**
* The consequence sentence the approver consents to. Keyed on the operation
* type; the one type whose outcome depends on its params (convert_invoice
* with target 'order' creates a draft kundorder, no F-number, nothing
* booked) reads the params so the dialog never promises a faktura that the
* approval will not create.
*/
export function singleActionWarning(operationType: string, params?: Record<string, unknown> | null): string {
if (operationType === 'convert_invoice' && params?.target === 'order') {
return 'Genom att klicka godkänn så skapas en kundorder (utkast, OR-nummer) från proforman eller offerten. Ingen faktura skapas och inget bokförs; fakturan skapas senare från kundordern.'
}
return singleActionWarnings[operationType] ?? ''
}
@@ -238,6 +238,7 @@ describe('gnubok_convert_invoice: quotes', () => {
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(
@@ -248,12 +249,133 @@ describe('gnubok_convert_invoice: quotes', () => {
)) as { staged: boolean; message: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const row = findCall('pending_operations', 'insert')![0] as { title: string }
expect(row.title).toContain('Konvertera offert')
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', () => {
@@ -344,6 +466,17 @@ describe('gnubok_set_quote_status: concurrency and expiry', () => {
).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 })
+67 -22
View File
@@ -7233,7 +7233,7 @@ export const tools: McpTool[] = [
actor,
isQuote
? {
description: 'Once approved, the quote exists as an open offert with its OF-number. Record the customer decision with gnubok_set_quote_status; gnubok_convert_invoice creates the faktura from it.',
description: 'Once approved, the quote exists as an open offert with its OF-number. Record the customer decision with gnubok_set_quote_status; gnubok_convert_invoice creates the faktura (or, with target order, a kundorder) from it.',
tool: 'gnubok_convert_invoice',
}
: {
@@ -7337,7 +7337,7 @@ export const tools: McpTool[] = [
additionalProperties: false,
properties: {
...SALES_ORDER_SUMMARY_PROPS,
source_invoice_id: { type: ['string', 'null'], description: 'Proforma the order was converted from, if any' },
source_invoice_id: { type: ['string', 'null'], description: 'Proforma or offert the order was converted from, if any' },
your_reference: { type: ['string', 'null'] },
our_reference: { type: ['string', 'null'] },
notes: { type: ['string', 'null'] },
@@ -18471,13 +18471,20 @@ export const tools: McpTool[] = [
{
name: 'gnubok_convert_invoice',
keywords: ['proforma', 'offert', 'quote', 'kundfaktura', 'omvandla'],
title: 'Convert Proforma or Quote to Invoice',
description: 'Stage conversion of a proforma or quote (offert) to a real invoice (F-number, items copied). Proforma is cancelled; the quote stays as accepted.',
keywords: ['proforma', 'offert', 'quote', 'kundfaktura', 'kundorder', 'omvandla'],
title: 'Convert Proforma or Quote to Invoice or Order',
description: 'Stage conversion of a proforma or quote (offert) to a real invoice (F-number, items copied) or, with target order, to a draft kundorder. Proforma is cancelled; the quote stays accepted.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { invoice_id: { type: 'string', description: 'Proforma or quote UUID' } },
properties: {
invoice_id: { type: 'string', description: 'Proforma or quote UUID' },
target: {
type: 'string',
enum: ['invoice', 'order'],
description: 'invoice (default) creates the faktura; order creates a draft kundorder to deliver and invoice from.',
},
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
@@ -18485,6 +18492,9 @@ export const tools: McpTool[] = [
async execute(args, companyId, userId, supabase, actor) {
const id = args.invoice_id as string
if (!id) throw new Error('invoice_id is required')
const target = (args.target as string | undefined) ?? 'invoice'
if (target !== 'invoice' && target !== 'order') throw new Error('target must be invoice or order')
const toOrder = target === 'order'
const { data: inv } = await supabase
.from('invoices')
@@ -18492,10 +18502,25 @@ export const tools: McpTool[] = [
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw registryError('INVOICE_NOT_FOUND')
const isQuote = inv.document_type === 'quote'
// Same pre-checks as convertToInvoice (the commit path), so the agent
// gets the registry code at staging time instead of a failed approval.
if (inv.document_type !== 'proforma' && !isQuote) throw registryError('INVOICE_CONVERT_NOT_CONVERTIBLE')
if (inv.status === 'cancelled') throw registryError('INVOICE_CONVERT_SOURCE_CANCELLED')
// Same pre-checks as convertToInvoice / convertToSalesOrder (the commit
// paths), so the agent gets the registry code at staging time instead
// of a failed approval.
if (inv.document_type !== 'proforma' && !isQuote) {
throw registryError(toOrder ? 'SALES_ORDER_SOURCE_NOT_PROFORMA' : 'INVOICE_CONVERT_NOT_CONVERTIBLE')
}
if (inv.status === 'cancelled') {
throw registryError(toOrder ? 'SALES_ORDER_SOURCE_ALREADY_CONVERTED' : 'INVOICE_CONVERT_SOURCE_CANCELLED')
}
if (toOrder) {
const { count: liveOrders, error: ordersError } = await supabase
.from('sales_orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_invoice_id', id)
.neq('status', 'cancelled')
if (ordersError) throw dbError(ordersError)
if ((liveOrders ?? 0) > 0) throw registryError('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
}
if (isQuote) {
if (inv.quote_status === 'declined') throw registryError('INVOICE_CONVERT_QUOTE_DECLINED')
const { data: converted, error: convertedError } = await supabase
@@ -18508,30 +18533,47 @@ export const tools: McpTool[] = [
.maybeSingle()
if (convertedError) throw dbError(convertedError)
if (converted) throw registryError('INVOICE_QUOTE_ALREADY_INVOICED')
if (!toOrder) {
const { count: liveOrders, error: ordersError } = await supabase
.from('sales_orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_invoice_id', id)
.neq('status', 'cancelled')
if (ordersError) throw dbError(ordersError)
if ((liveOrders ?? 0) > 0) throw registryError('INVOICE_QUOTE_ALREADY_ORDERED')
}
}
const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund'
const amount = `${roundOre(Number(inv.total))} ${inv.currency}`
const sourceLabel = isQuote ? 'offert' : 'proforma'
const targetLabel = toOrder ? 'kundorder' : 'faktura'
const sourceUpdate = isQuote ? 'mark the quote accepted (the quote stays)' : 'cancel proforma'
return stagePendingOperation(supabase, companyId, userId, 'convert_invoice',
isQuote
? `Konvertera offert → faktura: ${inv.invoice_number ?? ''} ${customerName} ${amount}`.replace(/\s+/g, ' ')
: `Konvertera proforma → faktura: ${customerName} ${amount}`,
{ invoice_id: id },
`Konvertera ${sourceLabel}${targetLabel}: ${isQuote ? inv.invoice_number ?? '' : ''} ${customerName} ${amount}`.replace(/\s+/g, ' '),
toOrder ? { invoice_id: id, target: 'order' } : { invoice_id: id },
{
customer_name: (inv.customer as { name?: string } | null)?.name,
source_document_type: inv.document_type,
source_invoice_number: inv.invoice_number ?? null,
target,
total: inv.total,
currency: inv.currency,
will: isQuote
? 'allocate F-series number, copy items, mark the quote accepted (the quote stays)'
: 'allocate F-series number, copy items, cancel proforma',
will: toOrder
? `allocate OR-series number, copy items into a draft kundorder, ${sourceUpdate}`
: `allocate F-series number, copy items, ${sourceUpdate}`,
},
actor,
{
description: 'After conversion, send the new invoice with gnubok_send_invoice.',
tool: 'gnubok_send_invoice',
}
toOrder
? {
description: 'After conversion, confirm the draft order with gnubok_transition_sales_order, then invoice deliveries with gnubok_create_invoice_from_sales_order.',
tool: 'gnubok_transition_sales_order',
}
: {
description: 'After conversion, send the new invoice with gnubok_send_invoice.',
tool: 'gnubok_send_invoice',
}
)
},
},
@@ -18540,7 +18582,7 @@ export const tools: McpTool[] = [
name: 'gnubok_set_quote_status',
keywords: ['offert', 'quote', 'accepterad', 'avböjd', 'godkänn offert'],
title: 'Set Quote Status',
description: 'Record the customer decision on a quote (offert): open, accepted or declined. Locked once invoiced; expired is derived from valid_until.',
description: 'Record the customer decision on a quote (offert): open, accepted or declined. Locked once invoiced or ordered; expired is derived from valid_until.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -18622,6 +18664,9 @@ export const tools: McpTool[] = [
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
throw registryError('INVOICE_QUOTE_ALREADY_INVOICED')
}
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_ORDERED')) {
throw registryError('INVOICE_QUOTE_ALREADY_ORDERED')
}
throw dbError(updateError)
}
if (!updated) throw registryError('INVOICE_QUOTE_CHANGED_CONCURRENTLY')
+18 -6
View File
@@ -1407,6 +1407,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Offerten är redan fakturerad och kan inte ändras.',
message_en: 'This quote has already been invoiced and can no longer change.',
},
INVOICE_QUOTE_ALREADY_ORDERED: {
httpStatus: 409,
message_sv: 'Offerten har redan en kundorder. Fakturera från kundordern i stället.',
message_en: 'This quote already has a sales order. Invoice from the sales order instead.',
},
INVOICE_CONVERT_NOT_CONVERTIBLE: {
httpStatus: 400,
message_sv: 'Endast proformafakturor och offerter kan omvandlas till faktura.',
@@ -1655,13 +1660,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
},
SALES_ORDER_SOURCE_NOT_PROFORMA: {
httpStatus: 400,
message_sv: 'Bara en proformafaktura kan omvandlas till kundorder.',
message_en: 'Only a proforma invoice can be converted into a sales order.',
message_sv: 'Bara en proformafaktura eller offert kan omvandlas till kundorder.',
message_en: 'Only a proforma invoice or a quote can be converted into a sales order.',
},
SALES_ORDER_SOURCE_UNSUPPORTED_LINES: {
httpStatus: 400,
message_sv: 'Proformafakturan innehåller rader som inte kan föras över till en kundorder (ROT/RUT-avdrag, periodisering eller negativt antal). Skapa kundordern manuellt.',
message_en: 'The proforma has lines that cannot be carried into a sales order (ROT/RUT deduction, accrual period or negative quantity). Create the sales order manually.',
message_sv: 'Underlaget innehåller rader som inte kan föras över till en kundorder (ROT/RUT-avdrag, periodisering eller negativt antal). Skapa kundordern manuellt.',
message_en: 'The source document has lines that cannot be carried into a sales order (ROT/RUT deduction, accrual period or negative quantity). Create the sales order manually.',
},
SALES_ORDER_CUSTOMER_VAT_CHANGED: {
httpStatus: 409,
@@ -1675,8 +1680,15 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
},
SALES_ORDER_SOURCE_ALREADY_CONVERTED: {
httpStatus: 409,
message_sv: 'Proformafakturan har redan omvandlats till en kundorder.',
message_en: 'The proforma has already been converted into a sales order.',
message_sv: 'Underlaget har redan en kundorder.',
message_en: 'The source document already has a sales order.',
},
SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE: {
httpStatus: 502,
message_sv:
'Kunde inte hämta växelkursen från Riksbanken för leverans-/fakturadatumet. Fakturan har inte skapats: en gissad kurs får inte bokföras. Försök igen om en stund.',
message_en:
'Could not fetch the Riksbanken exchange rate for the delivery/invoice date. No invoice was created: a guessed rate must not be booked. Try again shortly.',
},
// POST /api/invoices/{id}/peppol/send. The Access Point is an environment
// decision (PEPPOL_TRANSPORT_PROVIDER + adapter credentials); the product
+25 -2
View File
@@ -19,8 +19,9 @@ import type { Invoice } from '@/types'
* invoice links back via converted_from_id. A quote has no due date, so
* the invoice gets invoice_date + the customer's payment terms (falling
* back to company_settings.invoice_default_days, then 30). A second
* conversion is refused while an active converted invoice exists; a
* declined quote must be re-accepted first.
* conversion is refused while an active converted invoice exists or
* while a live kundorder was created from the quote (invoice from the
* order instead); a declined quote must be re-accepted first.
*
* Ordering: ensureInvoiceNumber() is the LAST side effect. The F-series
* counter only advances after items are inserted and the source is updated,
@@ -35,6 +36,7 @@ export type ConvertToInvoiceFailureCode =
| 'INVOICE_CONVERT_SOURCE_CHANGED'
| 'INVOICE_CONVERT_QUOTE_DECLINED'
| 'INVOICE_QUOTE_ALREADY_INVOICED'
| 'INVOICE_QUOTE_ALREADY_ORDERED'
export type ConvertToInvoiceResult =
| { ok: true; invoice: Invoice }
@@ -128,6 +130,22 @@ export async function convertToInvoice(params: {
return { ok: false, code: 'INVOICE_QUOTE_ALREADY_INVOICED' }
}
// A quote that became a kundorder is invoiced from the order (full or
// partial deliveries), never a second time from the quote. A cancelled
// order frees the quote again. Mirror of convertToSalesOrder's guard.
const { count: liveOrders, error: ordersError } = await supabase
.from('sales_orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_invoice_id', sourceId)
.neq('status', 'cancelled')
if (ordersError) {
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: ordersError }
}
if ((liveOrders ?? 0) > 0) {
return { ok: false, code: 'INVOICE_QUOTE_ALREADY_ORDERED' }
}
// 0 days is a real term (due on receipt); only a missing value falls
// through to the company default and then to 30.
let termsDays: number | null = source.customer?.default_payment_terms ?? null
@@ -223,6 +241,11 @@ export async function convertToInvoice(params: {
if ((invoiceError as { code?: string }).code === '23505') {
return { ok: false, code: isQuote ? 'INVOICE_QUOTE_ALREADY_INVOICED' : 'INVOICE_CONVERT_SOURCE_CHANGED' }
}
// invoices_converted_source_guard (migration 20260908165000): a kundorder
// from the quote became live between the pre-check and this insert.
if (String((invoiceError as { message?: string }).message ?? '').includes('INVOICE_QUOTE_ALREADY_ORDERED')) {
return { ok: false, code: 'INVOICE_QUOTE_ALREADY_ORDERED' }
}
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: invoiceError }
}
+10
View File
@@ -189,6 +189,7 @@ import { createSalesOrder } from '@/lib/sales-orders/write'
import { transitionSalesOrder } from '@/lib/sales-orders/transitions'
import { registerSalesOrderDelivery } from '@/lib/sales-orders/register-delivery'
import { createInvoiceFromSalesOrder } from '@/lib/sales-orders/create-invoice-from-order'
import { convertToSalesOrder } from '@/lib/sales-orders/convert-to-sales-order'
import type { ServiceFailure } from '@/lib/sales-orders/result'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
@@ -5479,6 +5480,15 @@ async function commitConvertInvoice(
const id = params.invoice_id as string
if (!id) return { error: 'invoice_id is required', status: 400 }
// target 'order': shared with POST /api/invoices/[id]/convert-to-order,
// proforma or quote to a draft kundorder. Staged under the same operation
// type as the invoice conversion; the target rides in the params.
if (params.target === 'order') {
const converted = await convertToSalesOrder(supabase, { companyId, userId, invoiceId: id })
if (!converted.ok) return salesOrderFailure(converted)
return { data: { sales_order_id: converted.order.id, order_number: converted.order.order_number } }
}
// Shared with POST /api/invoices/[id]/convert: proforma or quote to
// invoice, F-number allocated last, source cancelled (proforma) or
// accepted (quote).
@@ -307,6 +307,42 @@ describe('createInvoiceFromSalesOrder', () => {
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: [] })
+12
View File
@@ -45,6 +45,18 @@ describe('codeFromPgError', () => {
).toBe('SALES_ORDER_HAS_INVOICES')
})
it('maps the one-live-order-per-source index and the quote source guard (20260908165000)', () => {
expect(
codeFromPgError({
code: '23505',
message: 'duplicate key value violates unique constraint "uq_sales_orders_one_live_per_source"',
}),
).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
expect(
codeFromPgError({ code: 'P0001', message: 'INVOICE_QUOTE_ALREADY_INVOICED: quote q-1 has a live converted invoice' }),
).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
})
it('returns null for anything else (the raw error is surfaced instead)', () => {
expect(codeFromPgError({ message: 'null value in column "description"', code: '23502' })).toBeNull()
expect(codeFromPgError(new Error('connection reset'))).toBeNull()
-110
View File
@@ -1,110 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceItem, SalesOrder } from '@/types'
import { createSalesOrder } from './write'
import { fail, failDb, type ServiceResult } from './result'
/**
* Proforma -> kundorder. The proforma is the closest thing to an offert
* the product has today, so this is the "Skapa order" action on it. Copies
* header + lines into a new DRAFT order (source_invoice_id back-pointer)
* and, like proforma -> invoice, marks the proforma cancelled: the order
* now carries the agreement. Only one order per proforma.
*
* Order lines have no ROT/RUT or periodisering fields and no negative
* quantities. A proforma carrying any of those is refused outright
* (SALES_ORDER_SOURCE_UNSUPPORTED_LINES) instead of silently losing the
* skattereduktion or the accrual on the invoice that the order later
* produces.
*/
export async function convertProformaToSalesOrder(
supabase: SupabaseClient,
params: { companyId: string; userId: string; invoiceId: string },
): Promise<ServiceResult<{ order: SalesOrder }>> {
const { companyId, userId, invoiceId } = params
const { data: proforma, error } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.maybeSingle<Invoice & { items: InvoiceItem[] }>()
if (error) return failDb(error)
if (!proforma) return fail('INVOICE_NOT_FOUND')
if (proforma.document_type !== 'proforma') return fail('SALES_ORDER_SOURCE_NOT_PROFORMA')
if (proforma.status === 'cancelled') return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
const { count } = await supabase
.from('sales_orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_invoice_id', invoiceId)
if ((count ?? 0) > 0) return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
if (!proforma.customer_id) return fail('SALES_ORDER_CUSTOMER_MISSING')
const sourceItems = [...(proforma.items ?? [])].sort((a, b) => a.sort_order - b.sort_order)
const unsupported = sourceItems.filter(
(item) =>
(item.line_type ?? 'product') === 'product' &&
(Boolean(item.deduction_type) ||
Boolean(item.accrual_period_start) ||
Boolean(item.accrual_period_end) ||
Boolean(item.accrual_balance_account) ||
item.quantity < 0),
)
if (unsupported.length > 0) {
return fail('SALES_ORDER_SOURCE_UNSUPPORTED_LINES', {
lines: unsupported.map((item) => ({
invoice_item_id: item.id,
deduction_type: item.deduction_type ?? null,
accrual: Boolean(item.accrual_period_start || item.accrual_period_end),
quantity: item.quantity,
})),
})
}
const items = sourceItems.map((item) => ({
line_type: (item.line_type ?? 'product') as 'product' | 'text',
description: item.description,
quantity: item.line_type === 'text' ? 0 : item.quantity,
unit: item.unit ?? 'st',
unit_price: item.unit_price,
discount_percent: item.discount_percent ?? null,
vat_rate: item.vat_rate,
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length === 0) return fail('SALES_ORDER_NOTHING_TO_INVOICE')
const created = await createSalesOrder(supabase, {
companyId,
userId,
sourceInvoiceId: invoiceId,
input: {
customer_id: proforma.customer_id,
currency: proforma.currency,
your_reference: proforma.your_reference ?? null,
our_reference: proforma.our_reference ?? null,
notes: proforma.notes ?? null,
default_dimensions: proforma.default_dimensions ?? {},
items,
},
})
if (!created.ok) return created
// Compare-and-set so a concurrent convert (to invoice or to order) cannot
// both succeed; on a lost race the fresh draft order is removed again.
const { data: marked, error: markError } = await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', invoiceId)
.eq('company_id', companyId)
.neq('status', 'cancelled')
.select('id')
if (markError || !marked || marked.length === 0) {
await supabase.from('sales_orders').delete().eq('id', created.order.id).eq('company_id', companyId)
if (markError) return failDb(markError)
return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
}
return created
}
+167
View File
@@ -0,0 +1,167 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceItem, SalesOrder } from '@/types'
import { createSalesOrder } from './write'
import { codeFromPgError, fail, failDb, type ServiceResult } from './result'
/**
* Proforma or offert (quote) -> kundorder: the "Skapa order" action on both.
* Copies header + lines into a new DRAFT order (source_invoice_id
* back-pointer). Only one live order per source.
*
* Source handling differs by document type, mirroring convertToInvoice:
* - proforma: marked cancelled (it was a pre-document for the same sale);
* the order now carries the agreement.
* - quote: stays as the customer's accepted agreement and flips to
* quote_status = 'accepted' with a compare-and-set on the decision that
* was read. A declined quote must be re-opened first, and a quote with a
* live converted invoice (converted_from_id) is refused: the sale is
* already invoiced. The reverse guard lives in convertToInvoice
* (INVOICE_QUOTE_ALREADY_ORDERED).
*
* Order lines have no ROT/RUT or periodisering fields and no negative
* quantities. A source carrying any of those is refused outright
* (SALES_ORDER_SOURCE_UNSUPPORTED_LINES) instead of silently losing the
* skattereduktion or the accrual on the invoice that the order later
* produces.
*/
export async function convertToSalesOrder(
supabase: SupabaseClient,
params: { companyId: string; userId: string; invoiceId: string },
): Promise<ServiceResult<{ order: SalesOrder }>> {
const { companyId, userId, invoiceId } = params
const { data: source, error } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.maybeSingle<Invoice & { items: InvoiceItem[] }>()
if (error) return failDb(error)
if (!source) return fail('INVOICE_NOT_FOUND')
const isQuote = source.document_type === 'quote'
if (source.document_type !== 'proforma' && !isQuote) return fail('SALES_ORDER_SOURCE_NOT_PROFORMA')
if (source.status === 'cancelled') return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
if (isQuote && source.quote_status === 'declined') return fail('INVOICE_CONVERT_QUOTE_DECLINED')
// One live order per source. A cancelled order frees the source again
// (the proforma is cancelled with its order anyway, so this only matters
// for quotes).
const { count, error: countError } = await supabase
.from('sales_orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_invoice_id', invoiceId)
.neq('status', 'cancelled')
if (countError) return failDb(countError)
if ((count ?? 0) > 0) return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
if (isQuote) {
// Same guard as convertToInvoice in the other direction: the sale must
// not exist both as a live invoice and as an order.
const { data: invoiced, error: invoicedError } = await supabase
.from('invoices')
.select('id')
.eq('company_id', companyId)
.eq('converted_from_id', invoiceId)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (invoicedError) return failDb(invoicedError)
if (invoiced) return fail('INVOICE_QUOTE_ALREADY_INVOICED')
}
if (!source.customer_id) return fail('SALES_ORDER_CUSTOMER_MISSING')
const sourceItems = [...(source.items ?? [])].sort((a, b) => a.sort_order - b.sort_order)
const unsupported = sourceItems.filter(
(item) =>
(item.line_type ?? 'product') === 'product' &&
(Boolean(item.deduction_type) ||
Boolean(item.accrual_period_start) ||
Boolean(item.accrual_period_end) ||
Boolean(item.accrual_balance_account) ||
item.quantity < 0),
)
if (unsupported.length > 0) {
return fail('SALES_ORDER_SOURCE_UNSUPPORTED_LINES', {
lines: unsupported.map((item) => ({
invoice_item_id: item.id,
deduction_type: item.deduction_type ?? null,
accrual: Boolean(item.accrual_period_start || item.accrual_period_end),
quantity: item.quantity,
})),
})
}
const items = sourceItems.map((item) => ({
line_type: (item.line_type ?? 'product') as 'product' | 'text',
description: item.description,
quantity: item.line_type === 'text' ? 0 : item.quantity,
unit: item.unit ?? 'st',
unit_price: item.unit_price,
discount_percent: item.discount_percent ?? null,
vat_rate: item.vat_rate,
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length === 0) return fail('SALES_ORDER_NOTHING_TO_INVOICE')
const created = await createSalesOrder(supabase, {
companyId,
userId,
sourceInvoiceId: invoiceId,
input: {
customer_id: source.customer_id,
currency: source.currency,
your_reference: source.your_reference ?? null,
our_reference: source.our_reference ?? null,
notes: source.notes ?? null,
default_dimensions: source.default_dimensions ?? {},
items,
},
})
if (!created.ok) {
// The database holds the atomic guards (migration 20260908165000): a
// second live order for the source, or a live converted invoice on the
// quote, refuses the insert with a code even when the pre-checks above
// raced with another conversion.
if ('dbError' in created) {
const code = codeFromPgError(created.dbError)
if (code) return fail(code)
}
return created
}
// Compare-and-set so a concurrent cancel or quote decision cannot both
// succeed; on a lost race the fresh draft order is removed again. (A
// concurrent conversion is refused by the database at the insert above:
// for an already-accepted quote this accepted -> accepted update would
// not notice it.) Literal payloads on purpose: the phantom-column schema
// guard can only check object literals.
const { data: marked, error: markError } = isQuote
? await supabase
.from('invoices')
.update({
quote_status: 'accepted',
quote_decided_at: source.quote_decided_at ?? new Date().toISOString(),
})
.eq('id', invoiceId)
.eq('company_id', companyId)
.eq('quote_status', source.quote_status)
.neq('status', 'cancelled')
.select('id')
: await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', invoiceId)
.eq('company_id', companyId)
.neq('status', 'cancelled')
.select('id')
if (markError || !marked || marked.length === 0) {
await supabase.from('sales_orders').delete().eq('id', created.order.id).eq('company_id', companyId)
if (markError) return failDb(markError)
return fail('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
}
return created
}
@@ -206,6 +206,12 @@ export async function createInvoiceFromSalesOrder(
if ('dbError' in build) return failDb(build.dbError)
return fail(build.code, build.details)
}
// Fail closed on a missing Riksbanken rate, like convertToInvoice does: the
// builder leaves exchange_rate NULL on a miss and resolveSekAmount() would
// then book the foreign amount 1:1 as kronor on 1510/3xxx/26xx.
if (order.currency !== 'SEK' && build.invoiceFields.exchange_rate == null) {
return fail('SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE', { currency: order.currency })
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
+6
View File
@@ -35,6 +35,12 @@ export function codeFromPgError(error: unknown): string | null {
if (message.includes('SALES_ORDER_QUANTITY_BELOW_INVOICED')) return 'SALES_ORDER_QUANTITY_BELOW_INVOICED'
if (message.includes('sales_order_items_delivered_within_ordered')) return 'SALES_ORDER_OVER_DELIVERED'
if (message.includes('SALES_ORDER_ITEM_NOT_FOUND')) return 'SALES_ORDER_LINE_NOT_FOUND'
// Migration 20260908165000: one live kundorder per source document, and a
// quote with a live converted invoice cannot get a live order. Raised by
// the partial unique index and the source guard trigger when a concurrent
// conversion slipped past the service pre-checks.
if (message.includes('uq_sales_orders_one_live_per_source')) return 'SALES_ORDER_SOURCE_ALREADY_CONVERTED'
if (message.includes('INVOICE_QUOTE_ALREADY_INVOICED')) return 'INVOICE_QUOTE_ALREADY_INVOICED'
// RESTRICT FKs: a line or order that a (possibly cancelled) invoice still
// references cannot be removed; the derived invoiced quantity is 0 for a
// cancelled invoice, so the service pre-checks let the delete through and
+4 -1
View File
@@ -4057,7 +4057,9 @@
"create_order": "Create order",
"create_order_failed_title": "Could not create the order",
"order_created_toast_title": "Sales order created",
"order_created_toast_description": "Sales order {number} was created from the proforma",
"order_created_toast_description": "Sales order {number} was created",
"quote_expired_order_title": "The quote has expired. Create an order anyway?",
"quote_expired_order_description": "Valid until {date} has passed. A sales order is created from the quote, which is marked as accepted.",
"def_converted_from": "Converted from",
"def_invoiced": "Invoiced",
"payment_section": "Payment",
@@ -8576,6 +8578,7 @@
"def_notes": "Notes",
"def_source_invoice": "Created from",
"source_proforma": "Proforma invoice",
"source_quote": "Quote {number}",
"def_delivery": "Delivery",
"def_invoicing": "Invoicing",
"section_lines": "Order lines",
+4 -1
View File
@@ -4057,7 +4057,9 @@
"create_order": "Skapa order",
"create_order_failed_title": "Kunde inte skapa ordern",
"order_created_toast_title": "Kundorder skapad",
"order_created_toast_description": "Kundorder {number} har skapats från proforman",
"order_created_toast_description": "Kundorder {number} har skapats",
"quote_expired_order_title": "Offerten har gått ut. Skapa order ändå?",
"quote_expired_order_description": "Giltig till {date} har passerat. En kundorder skapas från offerten, som markeras som accepterad.",
"def_converted_from": "Konverterad från",
"def_invoiced": "Fakturerad",
"payment_section": "Betalning",
@@ -8576,6 +8578,7 @@
"def_notes": "Anteckningar",
"def_source_invoice": "Skapad från",
"source_proforma": "Proformafaktura",
"source_quote": "Offert {number}",
"def_delivery": "Leverans",
"def_invoicing": "Fakturering",
"section_lines": "Orderrader",
@@ -0,0 +1,117 @@
-- Offert -> kundorder (issue #2224): the sale behind a quote must never
-- exist twice. The services check for a live converted invoice and a live
-- kundorder before converting, but those reads are not serialized: two
-- conversions of an already-accepted quote (order + order, or order +
-- invoice) both pass their pre-checks, and the quote's compare-and-set
-- (accepted -> accepted) matches for both. Only the database can close
-- that window.
--
-- 1. One live kundorder per source document (partial unique index). A
-- cancelled order frees the source again, matching the service rule.
-- 2. A kundorder whose source is a quote cannot become live (insert, or
-- reopen from cancelled) while a live invoice was converted from that
-- quote. The trigger locks the quote row first (FOR UPDATE), so a
-- concurrent converted-invoice insert queues behind it and sees the
-- committed order.
-- 3. The mirror: an invoice with converted_from_id = a quote cannot become
-- live while a live kundorder points at that quote. Same lock, same
-- serialization, raised with the registry code the services map to 409
-- INVOICE_QUOTE_ALREADY_ORDERED.
--
-- Both triggers exit before the lock on every update that does not make a
-- row live (the common status changes), and never touch proformas: the
-- proforma path cancels its source, which its compare-and-set already
-- serializes.
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_orders_one_live_per_source
ON public.sales_orders (source_invoice_id)
WHERE source_invoice_id IS NOT NULL AND status <> 'cancelled';
CREATE OR REPLACE FUNCTION public.sales_orders_source_guard()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $function$
DECLARE
v_source_type text;
BEGIN
IF NEW.source_invoice_id IS NULL OR NEW.status = 'cancelled' THEN
RETURN NEW;
END IF;
-- Only a row that BECOMES live is checked: an insert, a reopen from
-- cancelled, or a re-pointed source.
IF TG_OP = 'UPDATE'
AND OLD.status <> 'cancelled'
AND OLD.source_invoice_id IS NOT DISTINCT FROM NEW.source_invoice_id THEN
RETURN NEW;
END IF;
SELECT i.document_type INTO v_source_type
FROM public.invoices i
WHERE i.id = NEW.source_invoice_id
FOR UPDATE;
IF v_source_type = 'quote' AND EXISTS (
SELECT 1 FROM public.invoices c
WHERE c.converted_from_id = NEW.source_invoice_id
AND c.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_INVOICED: quote % has a live converted invoice', NEW.source_invoice_id
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$function$;
DROP TRIGGER IF EXISTS trg_sales_orders_source_guard ON public.sales_orders;
CREATE TRIGGER trg_sales_orders_source_guard
BEFORE INSERT OR UPDATE OF status, source_invoice_id ON public.sales_orders
FOR EACH ROW
EXECUTE FUNCTION public.sales_orders_source_guard();
CREATE OR REPLACE FUNCTION public.invoices_converted_source_guard()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $function$
DECLARE
v_source_type text;
BEGIN
IF NEW.converted_from_id IS NULL OR NEW.status = 'cancelled' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.status <> 'cancelled'
AND OLD.converted_from_id IS NOT DISTINCT FROM NEW.converted_from_id THEN
RETURN NEW;
END IF;
SELECT i.document_type INTO v_source_type
FROM public.invoices i
WHERE i.id = NEW.converted_from_id
FOR UPDATE;
IF v_source_type = 'quote' AND EXISTS (
SELECT 1 FROM public.sales_orders o
WHERE o.source_invoice_id = NEW.converted_from_id
AND o.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_ORDERED: quote % has a live kundorder', NEW.converted_from_id
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$function$;
DROP TRIGGER IF EXISTS trg_invoices_converted_source_guard ON public.invoices;
CREATE TRIGGER trg_invoices_converted_source_guard
BEFORE INSERT OR UPDATE OF status, converted_from_id ON public.invoices
FOR EACH ROW
EXECUTE FUNCTION public.invoices_converted_source_guard();
COMMENT ON FUNCTION public.sales_orders_source_guard() IS
'A kundorder created from a quote cannot become live while a live invoice was converted from that quote. Locks the quote row so concurrent conversions serialize. Raises INVOICE_QUOTE_ALREADY_INVOICED.';
COMMENT ON FUNCTION public.invoices_converted_source_guard() IS
'An invoice converted from a quote cannot become live while a live kundorder was created from that quote. Locks the quote row so concurrent conversions serialize. Raises INVOICE_QUOTE_ALREADY_ORDERED.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,145 @@
-- Follow-up to 20260908165000_quote_source_conversion_guards (issue #2224).
--
-- 1. The two source guards run their SELECT ... FOR UPDATE on the quote row
-- as the invoker. Under RLS a FOR UPDATE also applies the UPDATE policy,
-- and invoices_update requires the row's company to be the caller's
-- ACTIVE company. A member of several companies writing through raw
-- PostgREST for a non-active company therefore got zero rows back: no
-- lock, v_source_type NULL, guard skipped. SECURITY DEFINER makes the
-- lookup see the row regardless (the guards read document_type and
-- liveness only; they grant nothing). Because a definer lookup sees every
-- company's rows, both guards now also require the source document to
-- belong to the same company as the row being written: a foreign
-- source_invoice_id / converted_from_id is refused outright instead of
-- locking and inspecting another tenant's quote.
-- 2. A quote with a live kundorder is the customer's accepted agreement
-- behind that order. The decision guard only knew converted invoices, so
-- the quote could still be moved to open or declined while the order was
-- being delivered and invoiced. It now also refuses leaving 'accepted'
-- while a live kundorder points at the quote, raising the registry code
-- the decision writers map to 409 INVOICE_QUOTE_ALREADY_ORDERED. Same
-- definer treatment for the same reason.
CREATE OR REPLACE FUNCTION public.sales_orders_source_guard()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_source_type text;
v_source_company uuid;
BEGIN
IF NEW.source_invoice_id IS NULL OR NEW.status = 'cancelled' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.status <> 'cancelled'
AND OLD.source_invoice_id IS NOT DISTINCT FROM NEW.source_invoice_id THEN
RETURN NEW;
END IF;
SELECT i.document_type, i.company_id INTO v_source_type, v_source_company
FROM public.invoices i
WHERE i.id = NEW.source_invoice_id
FOR UPDATE;
IF v_source_company IS DISTINCT FROM NEW.company_id THEN
RAISE EXCEPTION 'SALES_ORDER_SOURCE_COMPANY_MISMATCH: source document % does not belong to company %', NEW.source_invoice_id, NEW.company_id
USING ERRCODE = '42501';
END IF;
IF v_source_type = 'quote' AND EXISTS (
SELECT 1 FROM public.invoices c
WHERE c.converted_from_id = NEW.source_invoice_id
AND c.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_INVOICED: quote % has a live converted invoice', NEW.source_invoice_id
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$function$;
CREATE OR REPLACE FUNCTION public.invoices_converted_source_guard()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_source_type text;
v_source_company uuid;
BEGIN
IF NEW.converted_from_id IS NULL OR NEW.status = 'cancelled' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.status <> 'cancelled'
AND OLD.converted_from_id IS NOT DISTINCT FROM NEW.converted_from_id THEN
RETURN NEW;
END IF;
SELECT i.document_type, i.company_id INTO v_source_type, v_source_company
FROM public.invoices i
WHERE i.id = NEW.converted_from_id
FOR UPDATE;
IF v_source_company IS DISTINCT FROM NEW.company_id THEN
RAISE EXCEPTION 'INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH: source document % does not belong to company %', NEW.converted_from_id, NEW.company_id
USING ERRCODE = '42501';
END IF;
IF v_source_type = 'quote' AND EXISTS (
SELECT 1 FROM public.sales_orders o
WHERE o.source_invoice_id = NEW.converted_from_id
AND o.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_ORDERED: quote % has a live kundorder', NEW.converted_from_id
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$function$;
COMMENT ON FUNCTION public.sales_orders_source_guard() IS
'A kundorder created from a quote cannot become live while a live invoice was converted from that quote. Runs as definer, locks the quote row so concurrent conversions serialize, and refuses a source from another company. Raises INVOICE_QUOTE_ALREADY_INVOICED / SALES_ORDER_SOURCE_COMPANY_MISMATCH.';
COMMENT ON FUNCTION public.invoices_converted_source_guard() IS
'An invoice converted from a quote cannot become live while a live kundorder was created from that quote. Runs as definer, locks the quote row so concurrent conversions serialize, and refuses a source from another company. Raises INVOICE_QUOTE_ALREADY_ORDERED / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH.';
CREATE OR REPLACE FUNCTION public.invoices_quote_decision_guard()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
BEGIN
IF OLD.document_type = 'quote'
AND OLD.quote_status = 'accepted'
AND NEW.quote_status IS DISTINCT FROM 'accepted'
THEN
IF EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.converted_from_id = OLD.id
AND i.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_INVOICED: quote % has a live converted invoice', OLD.id
USING ERRCODE = 'P0001';
END IF;
IF EXISTS (
SELECT 1 FROM public.sales_orders o
WHERE o.source_invoice_id = OLD.id
AND o.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_ORDERED: quote % has a live kundorder', OLD.id
USING ERRCODE = 'P0001';
END IF;
END IF;
RETURN NEW;
END;
$function$;
COMMENT ON FUNCTION public.invoices_quote_decision_guard() IS
'A quote in accepted cannot leave accepted while a live invoice was converted from it (INVOICE_QUOTE_ALREADY_INVOICED) or a live kundorder was created from it (INVOICE_QUOTE_ALREADY_ORDERED).';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,295 @@
import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import { getClient, getPool, withUserContext } from './setup'
import { insertCompany, insertCompanyMember, seedCompany } from './fixtures'
// pg-real coverage for migrations 20260908165000 and 20260908165100 (issue
// #2224, offert -> kundorder): one live kundorder per source document, a
// quote with a live converted invoice cannot get a live order, a quote with
// a live order cannot get a live converted invoice, the quote decision is
// locked while an order lives, the guards run as definer so a non-active
// company is still guarded, and the two conversions serialize on the quote
// row so a concurrent pair cannot both land.
async function insertCustomer(companyId: string, userId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Testbrand AB', 'swedish_business')`,
[id, userId, companyId],
)
return id
}
async function insertSource(
companyId: string,
userId: string,
customerId: string,
documentType: 'quote' | 'proforma',
): Promise<string> {
const id = randomUUID()
const isQuote = documentType === 'quote'
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, valid_until, quote_status, currency,
subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5, $6,
'2026-06-01', '2026-07-01', $7, $8, 'SEK',
1000, 250, 1250, 'standard_25', 25, '10', 'sent')`,
[
id,
userId,
companyId,
customerId,
isQuote ? 'OF-001' : 'P-001',
documentType,
isQuote ? '2026-07-01' : null,
isQuote ? 'accepted' : null,
],
)
return id
}
function orderInsertSql(): string {
return `INSERT INTO public.sales_orders (id, company_id, user_id, customer_id, status, order_date, source_invoice_id)
VALUES ($1, $2, $3, $4, $5, '2026-09-01', $6)`
}
async function insertOrder(
companyId: string,
userId: string,
customerId: string,
sourceInvoiceId: string,
status: 'draft' | 'cancelled' = 'draft',
): Promise<string> {
const id = randomUUID()
await getPool().query(orderInsertSql(), [id, companyId, userId, customerId, status, sourceInvoiceId])
return id
}
function convertedInvoiceSql(): string {
return `INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status, converted_from_id)
VALUES ($1, $2, $3, $4, NULL, 'invoice',
'2026-09-01', '2026-10-01', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', $5, $6)`
}
async function insertConvertedInvoice(
companyId: string,
userId: string,
customerId: string,
convertedFromId: string,
status: 'draft' | 'cancelled' = 'draft',
): Promise<string> {
const id = randomUUID()
await getPool().query(convertedInvoiceSql(), [id, userId, companyId, customerId, status, convertedFromId])
return id
}
describe('quote source conversion guards (20260908165000)', () => {
it('allows one live kundorder per source and refuses a second one until the first is cancelled', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const first = await insertOrder(companyId, userId, customerId, quoteId)
await expect(insertOrder(companyId, userId, customerId, quoteId)).rejects.toThrow(
/uq_sales_orders_one_live_per_source/,
)
// A cancelled second order is not live and may point at the same source.
await insertOrder(companyId, userId, customerId, quoteId, 'cancelled')
await getPool().query(`UPDATE public.sales_orders SET status = 'cancelled' WHERE id = $1`, [first])
await expect(insertOrder(companyId, userId, customerId, quoteId)).resolves.toBeTruthy()
})
it('refuses a live converted invoice while a live kundorder points at the quote, and frees it on cancel', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const orderId = await insertOrder(companyId, userId, customerId, quoteId)
await expect(insertConvertedInvoice(companyId, userId, customerId, quoteId)).rejects.toThrow(
/INVOICE_QUOTE_ALREADY_ORDERED/,
)
// A cancelled converted invoice is not live and is allowed.
await insertConvertedInvoice(companyId, userId, customerId, quoteId, 'cancelled')
await getPool().query(`UPDATE public.sales_orders SET status = 'cancelled' WHERE id = $1`, [orderId])
await expect(insertConvertedInvoice(companyId, userId, customerId, quoteId)).resolves.toBeTruthy()
})
it('refuses a live kundorder (insert or reopen) while a live invoice was converted from the quote', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const cancelledOrder = await insertOrder(companyId, userId, customerId, quoteId, 'cancelled')
const invoiceId = await insertConvertedInvoice(companyId, userId, customerId, quoteId)
await expect(insertOrder(companyId, userId, customerId, quoteId)).rejects.toThrow(
/INVOICE_QUOTE_ALREADY_INVOICED/,
)
await expect(
getPool().query(`UPDATE public.sales_orders SET status = 'draft' WHERE id = $1`, [cancelledOrder]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_INVOICED/)
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [invoiceId])
await expect(
getPool().query(`UPDATE public.sales_orders SET status = 'draft' WHERE id = $1`, [cancelledOrder]),
).resolves.toBeTruthy()
})
it('leaves status changes that do not make a row live alone (confirm, complete, cancel)', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const orderId = await insertOrder(companyId, userId, customerId, quoteId)
// A converted invoice that slipped in before the order (cancelled now) must
// not block the order's own lifecycle.
await insertConvertedInvoice(companyId, userId, customerId, quoteId, 'cancelled')
await getPool().query(`UPDATE public.sales_orders SET status = 'confirmed' WHERE id = $1`, [orderId])
await getPool().query(`UPDATE public.sales_orders SET status = 'cancelled' WHERE id = $1`, [orderId])
const { rows } = await getPool().query<{ status: string }>(
'SELECT status FROM public.sales_orders WHERE id = $1',
[orderId],
)
expect(rows[0].status).toBe('cancelled')
})
it('does not gate proforma sources: the proforma path serializes on cancelling the proforma', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const proformaId = await insertSource(companyId, userId, customerId, 'proforma')
await insertConvertedInvoice(companyId, userId, customerId, proformaId)
await expect(insertOrder(companyId, userId, customerId, proformaId)).resolves.toBeTruthy()
})
it('runs under a member session: the quote row lock passes RLS and the guard raises its own code', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
// No live invoice: a member may create the order from the quote.
await withUserContext(userId, async (client) => {
await client.query(orderInsertSql(), [randomUUID(), companyId, userId, customerId, 'draft', quoteId])
})
// Live invoice: the refusal is the registry code, not a permission error
// from the FOR UPDATE lock on the quote row.
await insertConvertedInvoice(companyId, userId, customerId, quoteId)
await withUserContext(userId, async (client) => {
await expect(
client.query(orderInsertSql(), [randomUUID(), companyId, userId, customerId, 'draft', quoteId]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_INVOICED/)
})
})
it('locks the quote decision in accepted while a live kundorder exists (20260908165100)', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const orderId = await insertOrder(companyId, userId, customerId, quoteId)
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_ORDERED/)
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'open' WHERE id = $1`, [quoteId]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_ORDERED/)
// Re-affirming accepted (what the conversions write) is not a decision change.
await getPool().query(`UPDATE public.invoices SET quote_status = 'accepted' WHERE id = $1`, [quoteId])
await getPool().query(`UPDATE public.sales_orders SET status = 'cancelled' WHERE id = $1`, [orderId])
await getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId])
const { rows } = await getPool().query<{ quote_status: string }>(
'SELECT quote_status FROM public.invoices WHERE id = $1',
[quoteId],
)
expect(rows[0].quote_status).toBe('declined')
})
it('still guards a non-active company: the row lock runs as definer, not under the caller RLS (20260908165100)', async () => {
// One user, two companies, active company = A. The sales_orders insert
// policy admits every membership, but invoices_update (and so a FOR
// UPDATE under RLS) admits only the active company; without SECURITY
// DEFINER the guard saw no row for B and let the write through.
const { userId, companyId: companyA } = await seedCompany()
const companyB = await insertCompany({ createdBy: userId, name: 'Other AB' })
await insertCompanyMember({ companyId: companyB, userId, role: 'owner' })
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyA],
)
const customerB = await insertCustomer(companyB, userId)
const quoteB = await insertSource(companyB, userId, customerB, 'quote')
await insertConvertedInvoice(companyB, userId, customerB, quoteB)
await withUserContext(userId, async (client) => {
const { rows } = await client.query<{ active: string }>('SELECT public.current_active_company_id()::text AS active')
expect(rows[0].active).toBe(companyA)
await expect(
client.query(orderInsertSql(), [randomUUID(), companyB, userId, customerB, 'draft', quoteB]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_INVOICED/)
})
})
it('refuses a source document from another company instead of inspecting it as definer (20260908165100)', async () => {
const { userId, companyId: companyA } = await seedCompany()
const { userId: otherUser, companyId: companyB } = await seedCompany()
const customerA = await insertCustomer(companyA, userId)
const customerB = await insertCustomer(companyB, otherUser)
const quoteB = await insertSource(companyB, otherUser, customerB, 'quote')
await expect(insertOrder(companyA, userId, customerA, quoteB)).rejects.toThrow(
/SALES_ORDER_SOURCE_COMPANY_MISMATCH/,
)
await expect(insertConvertedInvoice(companyA, userId, customerA, quoteB)).rejects.toThrow(
/INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH/,
)
// A cancelled row points nowhere live and is not inspected either way.
await expect(insertOrder(companyA, userId, customerA, quoteB, 'cancelled')).resolves.toBeTruthy()
})
it('serializes a concurrent order + invoice conversion on the quote row so only the first lands', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(companyId, userId)
const quoteId = await insertSource(companyId, userId, customerId, 'quote')
const a = await getClient()
const b = await getClient()
try {
await a.query('BEGIN')
await b.query('BEGIN')
// A: the order insert takes the quote row lock and sees no invoice.
await a.query(orderInsertSql(), [randomUUID(), companyId, userId, customerId, 'draft', quoteId])
// B: the invoice insert queues behind A's lock instead of passing its
// own (stale) check.
const bInsert = b.query(convertedInvoiceSql(), [randomUUID(), userId, companyId, customerId, 'draft', quoteId])
const settledEarly = await Promise.race([
bInsert.then(() => 'settled', () => 'settled'),
new Promise<string>((resolve) => setTimeout(() => resolve('blocked'), 300)),
])
expect(settledEarly).toBe('blocked')
await a.query('COMMIT')
await expect(bInsert).rejects.toThrow(/INVOICE_QUOTE_ALREADY_ORDERED/)
await b.query('ROLLBACK')
} finally {
a.release()
b.release()
}
const { rows } = await getPool().query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.sales_orders WHERE source_invoice_id = $1 AND status <> 'cancelled'`,
[quoteId],
)
expect(rows[0].n).toBe('1')
})
})
+1 -1
View File
@@ -1038,7 +1038,7 @@ export interface SalesOrder {
/** OR-<n>, allocated at creation by generate_sales_order_number. */
order_number: string | null
status: SalesOrderStatus
/** Proforma the order was converted from, if any. */
/** Proforma or quote (offert) the order was converted from, if any. */
source_invoice_id: string | null
order_date: string
requested_delivery_date: string | null