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

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

"Skapa order" on an open or accepted quote creates a draft kundorder from
its lines. The quote stays as the customer's accepted agreement (flips to
quote_status accepted with a compare-and-set on the decision that was
read); the order is delivered and invoiced, in full or in parts, from the
kundorder page. Declined quotes are refused. Same action on the MCP side:
gnubok_convert_invoice takes target 'order', staged under the existing
convert_invoice operation type.

Why the problem occurred: the proforma -> order conversion refused every
source that was not a proforma, so the offert, which is what users
actually send before an order, could only become an invoice. The product
had both ends of the Fortnox flow (offert, kundorder) but no bridge.

What was removed or simplified: no second service and no new operation
type. The proforma conversion became the document conversion
(lib/sales-orders/convert-to-sales-order.ts) with the quote source as a
branch on the source update, mirroring how convertToInvoice already
treats the two. The MCP surface is one tool with a target parameter
rather than a sibling tool, which also gives proforma -> order the MCP
surface it did not have.

Why this shape: the sale must never exist twice. A quote with a live
converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED),
and a quote with a live kundorder cannot become an invoice a second time
(new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A
cancelled order or invoice frees the quote again. Rejected: cancelling the
quote like the proforma path (hides the accepted agreement), a separate
gnubok_convert_quote_to_order tool, and refusing expired quotes (the
invoice path allows them behind a confirm; the order path does the same).

Fixes #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxwavqBoG1HwFD5znkCGLv

* fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate

Skeptic refutations on the offert -> kundorder change:

1. An already-accepted quote could be converted twice concurrently (two
   orders, or an order and an invoice): the services' pre-checks are not
   serialized and the accepted -> accepted compare-and-set matches for
   every caller. Migration 20260908152555 adds a partial unique index
   (one live kundorder per source document) and two BEFORE triggers that
   lock the quote row and refuse a live order beside a live converted
   invoice and vice versa, so concurrent conversions queue and the second
   one sees the first. The services map the raised codes onto the same
   409s the pre-checks use. pg-real test covers the index, both
   directions, reopen from cancelled, the member-session lock, and the
   concurrent pair on two connections.

2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a
   NULL exchange rate when Riksbanken had none, which resolveSekAmount()
   then posts 1:1 as kronor. Pre-existing, but the quote now depends on
   the order path and the fail-closed quote -> invoice route is refused
   while an order lives. The order path now fails closed with
   SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice.

Refs #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order

The approval dialog's consequence sentence was keyed on operation_type
alone and promised a faktura with F-number for every convert_invoice.
With target 'order' the commit creates a draft kundorder and books
nothing, so the sentence now reads the params (skeptic refutation).

Refs #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page

Correctness skeptic refutations on the offert -> kundorder change:

1. A quote with a live kundorder could still be set to open or declined
   (dashboard route, v1, MCP): the decision guard only knew converted
   invoices. The dashboard then hid the re-accept button, so the quote
   was stuck as "Avböjd" behind a confirmed, invoiced order. Migration
   20260908155231 extends invoices_quote_decision_guard to refuse leaving
   accepted while a live kundorder points at the quote
   (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code.

2. The two source guards from 20260908152555 locked the quote row with a
   SELECT FOR UPDATE as the invoker. Under RLS that also applies the
   UPDATE policy, which admits only the caller's active company, so a
   multi-company member writing for another company through raw
   PostgREST got no row, no lock and no guard. All three guard functions
   are now SECURITY DEFINER. pg-real test covers the non-active company
   and the decision lock.

3. The kundorder page labelled every source "Proformafaktura". It now
   loads the source document and shows "Offert OF-nnn" for a quote; the
   MCP field description and the type comment say proforma or quote.

Refs #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards

CI: the target parameter and two description edits pushed the projected
tools/list payload to 60 502 tokens against the 60 500 ceiling; the same
facts now fit in fewer words (ceiling unchanged).

Superagent P2: the source guards run as definer since 20260908155231, so
a source_invoice_id or converted_from_id pointing at another company's
document would have locked and inspected that row. Both guards now
require the source to belong to the row's company and refuse otherwise
(SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH),
covered by a cross-company pg-real case. Migration 20260908155231 was
re-applied to staging under the same version (never on prod).

Refs #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(migrations): move the quote conversion guards to versions after main's 20260908164944

Main merged a later version while this branch was open; Supabase applies
pending versions in order, so both files are renamed to fresh versions
(20260908165000, 20260908165100) and re-tracked on staging under those.
Byte-identical SQL.

Refs #2224

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 18:29:37 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 32721b9f61
commit 9782f80db0
30 changed files with 1419 additions and 168 deletions
+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