* 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>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
|
|
import type { Invoice } from '@/types'
|
|
|
|
/**
|
|
* Convert a proforma or a quote (offert) into a real invoice.
|
|
*
|
|
* ONE implementation for every caller (the dashboard route, the MCP staged
|
|
* commit, v1): the proforma conversion used to exist twice and had drifted
|
|
* (one copy dropped per-line VAT and revenue accounts, the other dropped
|
|
* discounts and fakturamarkning). Both now flow through here.
|
|
*
|
|
* Source handling differs by document type:
|
|
* - proforma: the source is marked cancelled (it was a pre-invoice for the
|
|
* same sale) and its due_date is carried over.
|
|
* - quote: the source stays, flips to quote_status = 'accepted', and the
|
|
* 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 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,
|
|
* so a partial failure in any earlier step rolls back the orphan row without
|
|
* leaking a number (ML 17 kap 24 paragraph: gap-free lopnummer).
|
|
*/
|
|
|
|
export type ConvertToInvoiceFailureCode =
|
|
| 'INVOICE_NOT_FOUND'
|
|
| 'INVOICE_CONVERT_NOT_CONVERTIBLE'
|
|
| 'INVOICE_CONVERT_SOURCE_CANCELLED'
|
|
| 'INVOICE_CONVERT_SOURCE_CHANGED'
|
|
| 'INVOICE_CONVERT_QUOTE_DECLINED'
|
|
| 'INVOICE_QUOTE_ALREADY_INVOICED'
|
|
| 'INVOICE_QUOTE_ALREADY_ORDERED'
|
|
|
|
export type ConvertToInvoiceResult =
|
|
| { ok: true; invoice: Invoice }
|
|
| { ok: false; code: ConvertToInvoiceFailureCode }
|
|
| { ok: false; code: 'INVOICE_CONVERT_FAILED'; cause: Error | { message: string } }
|
|
|
|
interface SourceItem {
|
|
sort_order: number
|
|
line_type?: 'product' | 'text' | null
|
|
description: string
|
|
quantity: number
|
|
unit: string
|
|
unit_price: number
|
|
discount_percent?: number | null
|
|
line_total: number
|
|
vat_rate?: number | null
|
|
vat_amount?: number | null
|
|
revenue_account?: string | null
|
|
article_id?: string | null
|
|
sales_order_item_id?: string | null
|
|
dimensions?: Record<string, string> | null
|
|
}
|
|
|
|
type SourceRow = Invoice & {
|
|
items?: SourceItem[] | null
|
|
customer?: { default_payment_terms?: number | null } | null
|
|
}
|
|
|
|
const DEFAULT_PAYMENT_TERMS_DAYS = 30
|
|
|
|
function isoDate(d: Date): string {
|
|
return d.toISOString().split('T')[0]
|
|
}
|
|
|
|
function addDays(iso: string, days: number): string {
|
|
const d = new Date(`${iso}T00:00:00Z`)
|
|
d.setUTCDate(d.getUTCDate() + days)
|
|
return isoDate(d)
|
|
}
|
|
|
|
export async function convertToInvoice(params: {
|
|
supabase: SupabaseClient
|
|
userId: string
|
|
companyId: string
|
|
sourceId: string
|
|
}): Promise<ConvertToInvoiceResult> {
|
|
const { supabase, userId, companyId, sourceId } = params
|
|
|
|
const { data: sourceRaw, error: sourceError } = await supabase
|
|
.from('invoices')
|
|
.select('*, items:invoice_items(*), customer:customers(default_payment_terms)')
|
|
.eq('id', sourceId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (sourceError || !sourceRaw) {
|
|
return { ok: false, code: 'INVOICE_NOT_FOUND' }
|
|
}
|
|
const source = sourceRaw as SourceRow
|
|
const isQuote = source.document_type === 'quote'
|
|
|
|
if (source.document_type !== 'proforma' && !isQuote) {
|
|
return { ok: false, code: 'INVOICE_CONVERT_NOT_CONVERTIBLE' }
|
|
}
|
|
if (source.status === 'cancelled') {
|
|
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CANCELLED' }
|
|
}
|
|
|
|
const today = isoDate(new Date())
|
|
let dueDate: string = source.due_date
|
|
|
|
if (isQuote) {
|
|
if (source.quote_status === 'declined') {
|
|
return { ok: false, code: 'INVOICE_CONVERT_QUOTE_DECLINED' }
|
|
}
|
|
|
|
// One invoice per quote while that invoice lives. A cancelled converted
|
|
// invoice frees the quote for another attempt.
|
|
const { data: existing, error: existingError } = await supabase
|
|
.from('invoices')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('converted_from_id', sourceId)
|
|
.neq('status', 'cancelled')
|
|
.limit(1)
|
|
.maybeSingle()
|
|
if (existingError) {
|
|
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: existingError }
|
|
}
|
|
if (existing) {
|
|
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
|
|
if (termsDays == null) {
|
|
const { data: settings, error: settingsError } = await supabase
|
|
.from('company_settings')
|
|
.select('invoice_default_days')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
// A failed read must not silently become "30 days": that would date
|
|
// the receivable wrong. Only a successful empty result falls back.
|
|
if (settingsError) {
|
|
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: settingsError }
|
|
}
|
|
termsDays = (settings as { invoice_default_days?: number | null } | null)?.invoice_default_days ?? null
|
|
}
|
|
dueDate = addDays(today, termsDays ?? DEFAULT_PAYMENT_TERMS_DAYS)
|
|
}
|
|
|
|
// The invoice is a new taxable event dated today (ML 8 kap 21-23 §): a
|
|
// quote can sit for weeks, so the SEK twins must use today's rate, not
|
|
// the one stamped when the offer was written. Fail closed if no rate can
|
|
// be had (the create paths refuse a NULL rate for the same reason).
|
|
let exchangeRate = source.exchange_rate
|
|
let exchangeRateDate = source.exchange_rate_date
|
|
let subtotalSek = source.subtotal_sek
|
|
let vatAmountSek = source.vat_amount_sek
|
|
let totalSek = source.total_sek
|
|
if (source.currency !== 'SEK') {
|
|
const rate = await fetchExchangeRate(source.currency, new Date(`${today}T00:00:00Z`), supabase)
|
|
if (!rate) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVOICE_CONVERT_FAILED',
|
|
cause: { message: `Exchange rate for ${source.currency} unavailable; try again later` },
|
|
}
|
|
}
|
|
exchangeRate = rate.rate
|
|
exchangeRateDate = rate.date
|
|
subtotalSek = convertToSEK(source.subtotal, rate.rate)
|
|
vatAmountSek = convertToSEK(source.vat_amount, rate.rate)
|
|
totalSek = convertToSEK(source.total, rate.rate)
|
|
}
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: source.customer_id,
|
|
invoice_number: null,
|
|
invoice_date: today,
|
|
due_date: dueDate,
|
|
currency: source.currency,
|
|
exchange_rate: exchangeRate,
|
|
exchange_rate_date: exchangeRateDate,
|
|
subtotal: source.subtotal,
|
|
subtotal_sek: subtotalSek,
|
|
vat_amount: source.vat_amount,
|
|
vat_amount_sek: vatAmountSek,
|
|
total: source.total,
|
|
total_sek: totalSek,
|
|
// The converted invoice is a fresh unpaid receivable: proformas and
|
|
// quotes carry no ROT/RUT deduction, so the customer owes the full
|
|
// total. Omitting this left the NOT NULL DEFAULT 0, which every payment
|
|
// surface reads as "nothing open".
|
|
remaining_amount: source.total,
|
|
paid_amount: 0,
|
|
vat_treatment: source.vat_treatment,
|
|
vat_rate: source.vat_rate,
|
|
moms_ruta: source.moms_ruta,
|
|
reverse_charge_text: source.reverse_charge_text,
|
|
your_reference: source.your_reference,
|
|
our_reference: source.our_reference,
|
|
// Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
|
|
invoice_marking: source.invoice_marking ?? null,
|
|
notes: source.notes,
|
|
document_type: 'invoice',
|
|
converted_from_id: sourceId,
|
|
// Quote-only columns must not travel: the CHECK pairs quote_status
|
|
// with document_type = 'quote'.
|
|
valid_until: null,
|
|
quote_status: null,
|
|
quote_decided_at: null,
|
|
// Dimensions PR7: the converted invoice books with the source's bag.
|
|
default_dimensions: source.default_dimensions ?? {},
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError) {
|
|
// idx_invoices_one_live_conversion: a concurrent conversion won the race.
|
|
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 }
|
|
}
|
|
|
|
const items = (source.items ?? []).map((item) => ({
|
|
invoice_id: invoice.id,
|
|
sort_order: item.sort_order,
|
|
line_type: item.line_type ?? 'product',
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
// The stored line_total is net of this; dropping it would make the
|
|
// converted invoice fail the Peppol line check and lose the rebate on
|
|
// the next builder pass.
|
|
discount_percent: item.discount_percent ?? 0,
|
|
line_total: item.line_total,
|
|
// Per-line VAT and any article / revenue-account override travel too, so
|
|
// the invoice books exactly as the source showed (mixed rates and
|
|
// per-article accounts both rely on these).
|
|
vat_rate: item.vat_rate ?? 0,
|
|
vat_amount: item.vat_amount ?? 0,
|
|
revenue_account: item.revenue_account ?? null,
|
|
article_id: item.article_id ?? null,
|
|
// A kundorder link on a proforma line moves to the invoice, so the order
|
|
// keeps counting the quantity as invoiced once the proforma is cancelled.
|
|
sales_order_item_id: item.sales_order_item_id ?? null,
|
|
dimensions: item.dimensions ?? {},
|
|
}))
|
|
|
|
if (items.length > 0) {
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
|
if (itemsError) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: itemsError }
|
|
}
|
|
}
|
|
|
|
// Update the source. If this fails, the new (still unnumbered) invoice is
|
|
// an orphan: delete it so the user can retry without ending up with two
|
|
// active invoices for the same source. invoice_items cascade.
|
|
const previousSource = {
|
|
status: source.status,
|
|
quote_status: source.quote_status ?? null,
|
|
quote_decided_at: source.quote_decided_at ?? null,
|
|
}
|
|
// Literal payloads on purpose: the phantom-column schema guard
|
|
// (tests/schema/no-phantom-columns.test.ts) can only check object literals.
|
|
// Compare-and-set on the state read above: a concurrent cancel, a
|
|
// concurrent proforma-to-order conversion (which cancels the proforma) or
|
|
// a concurrent decision on the quote turns this into a 0-row update, and
|
|
// the orphan invoice is removed instead of a second document for the
|
|
// same sale surviving.
|
|
const { data: sourceRows, error: sourceUpdateError } = isQuote
|
|
? await supabase
|
|
.from('invoices')
|
|
.update({
|
|
quote_status: 'accepted',
|
|
quote_decided_at: source.quote_decided_at ?? new Date().toISOString(),
|
|
})
|
|
.eq('id', sourceId)
|
|
.eq('quote_status', source.quote_status)
|
|
.neq('status', 'cancelled')
|
|
.select('id')
|
|
: await supabase
|
|
.from('invoices')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', sourceId)
|
|
.neq('status', 'cancelled')
|
|
.select('id')
|
|
|
|
if (sourceUpdateError) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: sourceUpdateError }
|
|
}
|
|
if (!sourceRows || sourceRows.length === 0) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CHANGED' }
|
|
}
|
|
|
|
// Allocate the F-series number last. If allocation fails, restore the
|
|
// source and delete the orphan invoice. The F-counter is unaffected because
|
|
// generate_invoice_number only commits on success.
|
|
try {
|
|
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
|
} catch (err) {
|
|
if (isQuote) {
|
|
await supabase
|
|
.from('invoices')
|
|
.update({
|
|
quote_status: previousSource.quote_status,
|
|
quote_decided_at: previousSource.quote_decided_at,
|
|
})
|
|
.eq('id', sourceId)
|
|
} else {
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ status: previousSource.status })
|
|
.eq('id', sourceId)
|
|
}
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return {
|
|
ok: false,
|
|
code: 'INVOICE_CONVERT_FAILED',
|
|
cause: err instanceof Error ? err : { message: 'Failed to assign invoice number' },
|
|
}
|
|
}
|
|
|
|
const { data: completeInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoice.id)
|
|
.single()
|
|
|
|
const result = (completeInvoice ?? invoice) as Invoice
|
|
|
|
if (completeInvoice) {
|
|
await eventBus.emit({
|
|
type: 'invoice.created',
|
|
payload: { invoice: result, companyId, userId },
|
|
})
|
|
}
|
|
|
|
return { ok: true, invoice: result }
|
|
}
|