Files
accounted/lib/sales-orders/write.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

288 lines
12 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { z } from 'zod'
import type { Customer, SalesOrder, SalesOrderItem } from '@/types'
import type { CreateSalesOrderSchema, UpdateSalesOrderSchema } from '@/lib/api/schemas'
import { normalizeSalesOrderLines, type SalesOrderLineRow } from './lines'
import { todayIsoStockholm } from '@/lib/dates/iso'
import { ensureSalesOrderNumber } from './ensure-order-number'
import { fetchInvoicedQuantities, loadSalesOrder } from './load'
import { qtyGreater } from './progress'
import { codeFromPgError, fail, failDb, type ServiceResult } from './result'
export type CreateSalesOrderInput = z.infer<typeof CreateSalesOrderSchema>
export type UpdateSalesOrderInput = z.infer<typeof UpdateSalesOrderSchema>
const EDITABLE_STATUSES = new Set(['draft', 'confirmed'])
async function loadCustomer(
supabase: SupabaseClient,
companyId: string,
customerId: string,
): Promise<{ ok: true; customer: Customer } | { ok: false; code: string; details?: Record<string, unknown> }> {
const { data } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', companyId)
.maybeSingle<Customer>()
if (!data) return { ok: false, code: 'CUSTOMER_NOT_FOUND', details: { customerId } }
return { ok: true, customer: data }
}
/**
* Create a draft order with its lines. Number allocated at creation (orders
* are not verifikationer; gaps are irrelevant). A failed line insert rolls
* the header back so no empty order survives. The customer facts the lines
* were VAT-validated under are stored on the order; invoicing refuses when
* they have changed since (see create-invoice-from-order.ts).
*/
export async function createSalesOrder(
supabase: SupabaseClient,
params: { companyId: string; userId: string; input: CreateSalesOrderInput; sourceInvoiceId?: string | null },
): Promise<ServiceResult<{ order: SalesOrder }>> {
const { companyId, userId, input } = params
const customerRes = await loadCustomer(supabase, companyId, input.customer_id)
if (!customerRes.ok) return fail(customerRes.code, customerRes.details)
const customer = customerRes.customer
const lines = normalizeSalesOrderLines(input.items, customer)
if (!lines.ok) return fail(lines.code, lines.details)
const { data: header, error: headerError } = await supabase
.from('sales_orders')
.insert({
company_id: companyId,
user_id: userId,
customer_id: input.customer_id,
customer_type_snapshot: customer.customer_type,
customer_vat_validated_snapshot: customer.vat_number_validated ?? false,
status: 'draft',
source_invoice_id: params.sourceInvoiceId ?? null,
order_date: input.order_date ?? todayIsoStockholm(),
requested_delivery_date: input.requested_delivery_date ?? null,
currency: input.currency ?? 'SEK',
your_reference: input.your_reference ?? null,
our_reference: input.our_reference ?? null,
notes: input.notes ?? null,
default_dimensions: input.default_dimensions ?? {},
subtotal: lines.totals.subtotal,
vat_amount: lines.totals.vat_amount,
total: lines.totals.total,
})
.select('id')
.single<{ id: string }>()
if (headerError || !header) return failDb(headerError ?? new Error('sales order insert returned no row'))
const { error: itemsError } = await supabase
.from('sales_order_items')
.insert(lines.rows.map((row) => toInsertRow(row, companyId, header.id)))
if (itemsError) {
await supabase.from('sales_orders').delete().eq('id', header.id)
return failDb(itemsError)
}
// Non-fatal: an unnumbered order is still usable and gets numbered on the
// next write (the RPC is idempotent).
try {
await ensureSalesOrderNumber(supabase, companyId, header.id)
} catch {
// reported through the order's null order_number
}
return loadSalesOrder(supabase, companyId, header.id)
}
/**
* Full replace of header + lines while draft or confirmed.
*
* Lines are matched by id so a confirmed order keeps delivered/invoiced
* history on the lines that survive. A line that has been delivered or
* invoiced cannot be dropped (SALES_ORDER_LINE_LOCKED) and cannot go below
* its delivered quantity (SALES_ORDER_OVER_DELIVERED); the DB trigger
* additionally refuses lowering below the invoiced quantity. Once invoices
* exist, customer and currency are frozen (SALES_ORDER_HAS_INVOICES): a
* second partial invoice must go to the same customer in the same currency
* as the first. Every save re-validates the lines against the customer's
* current VAT rules and refreshes the stored snapshot.
*/
export async function updateSalesOrder(
supabase: SupabaseClient,
params: { companyId: string; orderId: string; input: UpdateSalesOrderInput },
): Promise<ServiceResult<{ order: SalesOrder }>> {
const { companyId, orderId, input } = params
const current = await loadSalesOrder(supabase, companyId, orderId)
if (!current.ok) return current
const order = current.order
if (!EDITABLE_STATUSES.has(order.status)) return fail('SALES_ORDER_NOT_EDITABLE', { status: order.status })
const customerChanged = input.customer_id !== undefined && input.customer_id !== order.customer_id
const currencyChanged = input.currency !== undefined && input.currency !== order.currency
if (customerChanged || currencyChanged) {
const open = await hasOpenInvoices(supabase, companyId, orderId)
if (!open.ok) return failDb(open.dbError)
if (open.open) return fail('SALES_ORDER_HAS_INVOICES', { field: customerChanged ? 'customer_id' : 'currency' })
}
const customerId = input.customer_id ?? order.customer_id
if (!customerId) return fail('SALES_ORDER_CUSTOMER_MISSING')
const customerRes = await loadCustomer(supabase, companyId, customerId)
if (!customerRes.ok) return fail(customerRes.code, customerRes.details)
const customer = customerRes.customer
const existing = new Map((order.items ?? []).map((i) => [i.id, i]))
const lineInputs =
input.items ??
(order.items ?? []).map((i) => ({
id: i.id,
line_type: i.line_type,
description: i.description,
quantity: i.quantity,
unit: i.unit,
unit_price: i.unit_price,
discount_percent: i.discount_percent,
vat_rate: i.vat_rate,
article_id: i.article_id,
revenue_account: i.revenue_account,
dimensions: i.dimensions,
}))
// Always re-validate against the customer's CURRENT VAT rules, even for a
// header-only save: a stored rate the customer may no longer carry is
// refused (INVOICE_CREATE_VAT_RULE_VIOLATION) and the snapshot below is
// only refreshed once the lines pass.
const lines = normalizeSalesOrderLines(lineInputs, customer)
if (!lines.ok) return fail(lines.code, lines.details)
const rows: SalesOrderLineRow[] | null = input.items ? lines.rows : null
const totals = lines.totals
if (rows) {
const incomingIds = new Set(rows.map((r) => r.id).filter((id): id is string => Boolean(id)))
for (const id of incomingIds) {
if (!existing.has(id)) return fail('SALES_ORDER_LINE_NOT_FOUND', { sales_order_item_id: id })
}
for (const row of rows) {
if (!row.id) continue
const prev = existing.get(row.id) as SalesOrderItem
if (qtyGreater(prev.delivered_qty, row.quantity)) {
return fail('SALES_ORDER_OVER_DELIVERED', { sales_order_item_id: row.id, delivered_qty: prev.delivered_qty })
}
if (qtyGreater(prev.invoiced_qty ?? 0, row.quantity)) {
return fail('SALES_ORDER_QUANTITY_BELOW_INVOICED', { sales_order_item_id: row.id, invoiced_qty: prev.invoiced_qty })
}
}
for (const prev of existing.values()) {
if (incomingIds.has(prev.id)) continue
if (prev.delivered_qty > 0 || (prev.invoiced_qty ?? 0) > 0) {
return fail('SALES_ORDER_LINE_LOCKED', { sales_order_item_id: prev.id })
}
}
}
// Object literal on purpose (schema guard): undefined keys are dropped by
// JSON serialisation, so an omitted field leaves the column untouched.
const { error: headerError } = await supabase
.from('sales_orders')
.update({
customer_id: input.customer_id,
customer_type_snapshot: customer.customer_type,
customer_vat_validated_snapshot: customer.vat_number_validated ?? false,
order_date: input.order_date,
requested_delivery_date: input.requested_delivery_date,
currency: input.currency,
your_reference: input.your_reference,
our_reference: input.our_reference,
notes: input.notes,
default_dimensions: input.default_dimensions,
subtotal: totals.subtotal,
vat_amount: totals.vat_amount,
total: totals.total,
})
.eq('id', orderId)
.eq('company_id', companyId)
if (headerError) return failDb(headerError)
if (rows) {
const keep = new Set(rows.map((r) => r.id).filter(Boolean))
const toDelete = [...existing.keys()].filter((id) => !keep.has(id))
if (toDelete.length > 0) {
const { error } = await supabase.from('sales_order_items').delete().in('id', toDelete).eq('company_id', companyId)
if (error) return mapPg(error)
}
for (const row of rows) {
if (!row.id) continue
const { error } = await supabase
.from('sales_order_items')
.update({
sort_order: row.sort_order,
line_type: row.line_type,
description: row.description,
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
discount_percent: row.discount_percent,
vat_rate: row.vat_rate,
line_total: row.line_total,
article_id: row.article_id,
revenue_account: row.revenue_account,
dimensions: row.dimensions,
})
.eq('id', row.id)
.eq('company_id', companyId)
if (error) return mapPg(error)
}
const inserts = rows.filter((r) => !r.id).map((r) => toInsertRow(r, companyId, orderId))
if (inserts.length > 0) {
const { error } = await supabase.from('sales_order_items').insert(inserts)
if (error) return mapPg(error)
}
}
return loadSalesOrder(supabase, companyId, orderId)
}
function toInsertRow(row: SalesOrderLineRow, companyId: string, orderId: string) {
return {
company_id: companyId,
sales_order_id: orderId,
sort_order: row.sort_order,
line_type: row.line_type,
description: row.description,
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
discount_percent: row.discount_percent,
vat_rate: row.vat_rate,
line_total: row.line_total,
article_id: row.article_id,
revenue_account: row.revenue_account,
dimensions: row.dimensions,
}
}
function mapPg(error: unknown): ServiceResult<never> {
const code = codeFromPgError(error)
return code ? fail(code) : failDb(error)
}
/** Progress-aware existence check reused by transitions and delivery. */
export async function hasOpenInvoices(
supabase: SupabaseClient,
companyId: string,
orderId: string,
): Promise<{ ok: true; open: boolean } | { ok: false; dbError: unknown }> {
const invoiced = await fetchInvoicedQuantities(supabase, [orderId])
if (!invoiced.ok) return invoiced
for (const qty of invoiced.byItem.values()) {
if (qty > 0) return { ok: true, open: true }
}
// Zero-quantity links (e.g. a draft with a 0 line) still tie the invoice
// to the order; count header links too.
const { count, error } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('sales_order_id', orderId)
.not('status', 'in', '("cancelled","credited")')
if (error) return { ok: false, dbError: error }
return { ok: true, open: (count ?? 0) > 0 }
}