Files
accounted/lib/invoices/replace-invoice-items.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

147 lines
6.1 KiB
TypeScript

import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceWriteItemRow } from '@/lib/invoices/build-invoice-write'
/**
* Replace ALL invoice_items rows of a DRAFT invoice with `items` (full-replace
* semantics: delete everything, then insert the new set with `invoice_id`
* stamped on).
*
* Only valid for editable drafts: a draft has no journal entry or linked
* documents, so delete + reinsert is safe and lets the caller add / remove /
* reorder rows freely (invoice_items cascade nothing else). The caller is
* responsible for the draft guard (isEditableInvoiceDraft) BEFORE calling.
*
* Delete + insert is not atomic over PostgREST, so the existing rows are
* snapshotted first and best-effort restored when the insert fails: without
* that, a rejected insert (constraint violation, transient error) left the
* draft with ZERO items and the user's line content gone. Same
* snapshot/restore idiom as commitUpdateRecurringSchedule in
* lib/pending-operations/commit.ts. `restored` on the insert-failure shape
* says whether the previous rows are back; the success path is unchanged.
* An unreadable snapshot refuses the replace outright: it is both the
* restore source and the input to the kundorder link guard below.
*
* Shared by the cookie PATCH route (app/api/invoices/[id]), the v1 REST PATCH
* route, and the update_invoice commit executor so the replace logic cannot
* drift between the surfaces.
*/
export type ReplaceInvoiceItemsResult =
| { ok: true }
/**
* Refused before any write: the draft was created from a kundorder and the
* new line set drops one or more sales_order_item_id links. The order's
* invoiced quantity is derived from those links, so losing them would free
* the quantity for a second invoice. Every caller (cookie PATCH, v1 PATCH,
* update_invoice executor) surfaces this as INVOICE_UPDATE_DROPS_ORDER_LINK.
*/
| { ok: false; stage: 'guard'; code: 'INVOICE_UPDATE_DROPS_ORDER_LINK'; messageSv: string }
| { ok: false; stage: 'delete'; error: PostgrestError }
| {
ok: false
stage: 'insert'
error: PostgrestError
/**
* Whether the pre-delete rows were put back after the failed insert.
* True when the restore insert succeeded (or there was nothing to
* restore); false when the draft may have been left without items and
* the caller should say so instead of reporting a clean failure.
*/
restored: boolean
}
/** Server-generated invoice_items columns that must not be re-inserted. */
const SERVER_GENERATED_COLUMNS = ['id', 'created_at', 'updated_at'] as const
export async function replaceInvoiceItems(
supabase: SupabaseClient,
invoiceId: string,
items: InvoiceWriteItemRow[],
): Promise<ReplaceInvoiceItemsResult> {
// Snapshot before deleting. select('*') on purpose: the restore must carry
// every content column, including ones added after this function was
// written, so an explicit list here would silently drop new fields.
const { data: snapshotRows, error: snapshotError } = await supabase
.from('invoice_items')
.select('*')
.eq('invoice_id', invoiceId)
// Order-link guard: a line set that forgets sales_order_item_id (a client
// that read the lines through a projection without it, or a header-only
// edit path that re-reads a narrow column list) must not silently sever
// the kundorder link. Compare the link multiset before deleting anything.
if (snapshotError || !snapshotRows) {
// Without the snapshot the guard cannot run and the restore path has
// nothing to put back: refuse rather than fail open on a draft that may
// carry order links.
return {
ok: false,
stage: 'delete',
error:
snapshotError ??
({ message: 'invoice_items snapshot unavailable', code: 'SNAPSHOT_UNAVAILABLE' } as PostgrestError),
}
}
{
const existingLinks = (snapshotRows as Array<{ sales_order_item_id?: string | null }>)
.map((row) => row.sales_order_item_id)
.filter((id): id is string => typeof id === 'string')
if (existingLinks.length > 0) {
const incoming = new Map<string, number>()
for (const item of items) {
const link = (item as { sales_order_item_id?: string | null }).sales_order_item_id
if (link) incoming.set(link, (incoming.get(link) ?? 0) + 1)
}
for (const link of existingLinks) {
const left = incoming.get(link) ?? 0
if (left === 0) {
return {
ok: false,
stage: 'guard',
code: 'INVOICE_UPDATE_DROPS_ORDER_LINK',
messageSv:
'Fakturan är skapad från en kundorder och ändringen skulle tappa kopplingen till orderraderna.',
}
}
incoming.set(link, left - 1)
}
}
}
const { error: deleteError } = await supabase
.from('invoice_items')
.delete()
.eq('invoice_id', invoiceId)
if (deleteError) return { ok: false, stage: 'delete', error: deleteError }
const { error: insertError } = await supabase
.from('invoice_items')
.insert(items.map((item) => ({ ...item, invoice_id: invoiceId })))
if (insertError) {
// Best-effort restore of the snapshot so the draft keeps its lines. A
// failed (or impossible) restore is reported, never swallowed.
// The snapshot is guaranteed here: an unreadable one refuses the replace
// before the delete (order-link guard above).
let restored = false
if (snapshotRows.length === 0) {
// Nothing existed before, so the draft is already in its prior state.
restored = true
} else {
const restoreRows = (snapshotRows as Record<string, unknown>[]).map((row) => {
const copy: Record<string, unknown> = { ...row }
for (const column of SERVER_GENERATED_COLUMNS) delete copy[column]
copy.invoice_id = invoiceId
return copy
})
const { error: restoreError } = await supabase
.from('invoice_items')
.insert(restoreRows)
restored = !restoreError
}
return { ok: false, stage: 'insert', error: insertError, restored }
}
return { ok: true }
}