feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)

* feat(invoices): draft invoice delete on v1 and MCP with staged approval

Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.

- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
  with an explicit userId param (service-role clients null auth.uid());
  the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
  INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
  route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
  outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
  approval, risk 'high' (both outcomes irreversible, never
  auto-committed), catalogVisibility 'search' (tools/list budget at zero
  headroom)
- delete_draft_invoice commit executor delegating to the shared service,
  plus pending_operations CHECK constraint migration pair
  (20260830100000/100001), risk tier, scope map, Granskning vocabulary
  and sv/en labels

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

* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main

Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.

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

* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint

apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.

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

* fix(invoices): pin staged delete outcome and align v1 risk metadata

Skeptic findings on PR #2036:

- Outcome pin: gnubok_delete_draft_invoice stages
  expected_invoice_number alongside invoice_id; the executor passes it to
  deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
  draft's number changed since staging. An unnumbered draft finalized
  between staging and approval is now auto-rejected with a message naming
  the new number, instead of silently switching from the approved hard
  delete to a makulering. Ops staged without the pin keep legacy
  semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
  the delete_draft_invoice pending-op tier (both outcomes irreversible);
  generated accounted-api docs regenerated.

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

* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision

Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-30 16:57:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 32cb8a22ce
commit 9f8fa1b692
21 changed files with 1257 additions and 83 deletions
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `139`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `140`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -8,6 +8,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId",
"DELETE /api/v1/companies/:companyId/employees/:id",
"DELETE /api/v1/companies/:companyId/employees/:id/absence",
"DELETE /api/v1/companies/:companyId/invoices/:id",
"DELETE /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links/:linkId",
"DELETE /api/v1/companies/:companyId/salary-runs/:id",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
+1
View File
@@ -254,6 +254,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_invoice_deliveries: 'invoices:read',
gnubok_create_invoice: 'invoices:write',
gnubok_update_invoice: 'invoices:write',
gnubok_delete_draft_invoice: 'invoices:write',
gnubok_send_invoice: 'invoices:write',
gnubok_mark_invoice_as_paid: 'invoices:write',
gnubok_mark_invoice_as_sent: 'invoices:write',
+3
View File
@@ -66,6 +66,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
'POST /api/v1/companies/:companyId/invoices': 'invoices:write',
'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
// Draft deletion: hard delete unnumbered drafts, makulering for numbered
// ones. Non-drafts are refused (credit note is the only reversal path).
'DELETE /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
// Phase 2 PR-B-2b: action verbs. URL uses /verb subpath (not Google-AIP-style :verb)
// because Next.js routes don't support `:` in folder names.
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write',
+172
View File
@@ -0,0 +1,172 @@
/**
* Draft customer-invoice deletion, shared by the cookie-session route
* (DELETE /api/invoices/[id]), the v1 API-key route
* (DELETE /api/v1/companies/{companyId}/invoices/{id}) and the MCP
* delete_draft_invoice executor.
*
* Behaviour depends on whether an F-series number was issued:
*
* - Unnumbered draft (saved via "Spara som utkast", never finalized): hard
* deleted. No F-series number was consumed, so there is no gap to document
* (ML 17 kap 24 paragraf). invoice_items cascade via the FK.
* - Numbered draft (created directly, or finalized via "Granska och skapa"):
* makulerad: the row and its number are retained and status flips to
* 'cancelled', keeping the F-series gap-free per ML 17 kap 24 paragraf /
* BFNAR 2013:2.
*
* Only drafts may be removed either way. Sent / paid invoices are immutable
* per BFL and must be reversed via a credit note instead. Posted journal
* entries and documents linked to them are never touched here: a draft has
* neither.
*
* The actor is an EXPLICIT parameter: v1 and MCP callers run on the
* service-role client, where auth.uid() is null, so the caller's user id can
* never be inferred from the client.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
export type DeleteDraftInvoiceResult =
/** Unnumbered draft hard-deleted; invoice.draft_deleted event emitted. */
| { ok: true; outcome: 'deleted' }
/** Numbered draft makulerad (status -> 'cancelled'), number retained. */
| { ok: true; outcome: 'cancelled'; invoiceNumber: string }
| { ok: false; code: 'INVOICE_NOT_FOUND' }
| { ok: false; code: 'INVOICE_DELETE_NOT_DRAFT'; currentStatus: string }
/**
* Status flipped between fetch and write (concurrent send/finalize), or the
* caller pinned expectedInvoiceNumber and the number changed since then
* (currentInvoiceNumber then carries what the draft holds now).
*/
| { ok: false; code: 'INVOICE_CANCEL_RACE'; currentInvoiceNumber?: string | null }
| { ok: false; code: 'INVOICE_DELETE_FAILED'; cause: { message: string; code?: string } }
export interface DeleteDraftInvoiceParams {
supabase: SupabaseClient
companyId: string
/**
* Acting user's id, recorded on the invoice.draft_deleted audit event.
* Passed explicitly: service-role clients null auth.uid().
*/
userId: string
invoiceId: string
/**
* Outcome pin for two-phase callers (stage now, execute after approval):
* the invoice_number observed at staging time (null for an unnumbered
* draft). When provided and the draft's number has changed since (an
* unnumbered draft was finalized), the call refuses with
* INVOICE_CANCEL_RACE instead of silently switching from the approved hard
* delete to a makulering. Omit (undefined) for single-phase callers (web,
* v1): they act on the state they just read.
*/
expectedInvoiceNumber?: string | null
log?: Logger
}
export async function deleteDraftInvoice(
params: DeleteDraftInvoiceParams,
): Promise<DeleteDraftInvoiceResult> {
const { supabase, companyId, userId, invoiceId, expectedInvoiceNumber, log } = params
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
return { ok: false, code: 'INVOICE_NOT_FOUND' }
}
if (invoice.status !== 'draft') {
return { ok: false, code: 'INVOICE_DELETE_NOT_DRAFT', currentStatus: invoice.status }
}
// Outcome pin: a two-phase caller approved a SPECIFIC outcome (hard delete
// for unnumbered, makulering of one number for numbered). If the number
// changed between staging and now (unnumbered draft finalized), the
// approved outcome no longer applies: refuse rather than switch paths.
// The unnumbered -> numbered transition is the only reachable change
// (numbers are never reassigned), and the write guards below still cover
// a flip inside this call.
const currentNumber = invoice.invoice_number ?? null
if (expectedInvoiceNumber !== undefined && currentNumber !== expectedInvoiceNumber) {
return { ok: false, code: 'INVOICE_CANCEL_RACE', currentInvoiceNumber: currentNumber }
}
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
// yet issued invoices (no F-series number was consumed) so they can be hard
// deleted with no gap in the sequence (ML 17 kap 24 paragraf). invoice_items
// cascade via the FK (ON DELETE CASCADE); an un-finalized draft has no
// journal entry or linked document. The status='draft' + invoice_number IS
// NULL guard makes the delete a no-op if the row was finalized (numbered)
// concurrently.
if (!invoice.invoice_number) {
const { data: removed, error: removeError } = await supabase
.from('invoices')
.delete()
.eq('id', invoiceId)
.eq('company_id', companyId)
.eq('status', 'draft')
.is('invoice_number', null)
.select('id')
if (removeError) {
log?.error('invoice draft delete failed', removeError)
return {
ok: false,
code: 'INVOICE_DELETE_FAILED',
cause: { message: removeError.message, code: removeError.code },
}
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete: refuse rather than fall through to
// makulering of a now-issued invoice.
return { ok: false, code: 'INVOICE_CANCEL_RACE' }
}
// The row is gone, so there's no journal trace of the removal. Emit an
// audit event carrying the identifiers so the event log records who
// deleted which draft and when: the makulering path leaves a
// journal/status trail, a hard delete otherwise leaves none.
await eventBus.emit({
type: 'invoice.draft_deleted',
payload: { invoiceId, companyId, userId },
})
return { ok: true, outcome: 'deleted' }
}
// Numbered draft: retain the row and its number, flip to 'cancelled'
// (makulering) so the F-series stays gap-free.
// .select() returns the affected rows so we can detect a TOCTOU race where
// the status flipped between the fetch above and this update. With only the
// .eq('status','draft') guard, a 0-row update returns success and the caller
// would see "Makulerad" while the invoice is still in its previous state.
const { data: updated, error: cancelError } = await supabase
.from('invoices')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', invoiceId)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (cancelError) {
log?.error('invoice cancellation failed', cancelError)
return {
ok: false,
code: 'INVOICE_DELETE_FAILED',
cause: { message: cancelError.message, code: cancelError.code },
}
}
if (!updated || updated.length === 0) {
return { ok: false, code: 'INVOICE_CANCEL_RACE' }
}
return { ok: true, outcome: 'cancelled', invoiceNumber: invoice.invoice_number }
}
@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingOperation } from '@/types'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { commitPendingOperation } from '../commit'
const INVOICE_ID = '22222222-2222-4222-8222-222222222222'
function makePendingOp(params: Record<string, unknown>): PendingOperation {
return {
id: 'op-delete-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'delete_draft_invoice',
status: 'pending',
title: 'Ta bort fakturautkast',
params,
preview_data: {},
result_data: null,
actor_type: 'api_key',
actor_id: 'key-1',
actor_label: 'Test key',
risk_level: 'high',
agent_metadata: null,
rejection_category: null,
rejection_reason: null,
created_at: '2026-08-30T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-30T00:00:00Z',
} as PendingOperation
}
function invoiceRow(overrides: Record<string, unknown> = {}) {
return {
id: INVOICE_ID,
status: 'draft',
invoice_number: null,
user_id: 'user-1',
credited_invoice_id: null,
journal_entry_id: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: delete_draft_invoice', () => {
it('hard deletes an unnumbered draft and emits the audit event with the explicit actor', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim pending -> committing
enqueue({ data: invoiceRow() }) // invoices: fetch (draft, unnumbered)
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices: delete().select('id')
enqueue({ data: null }) // pending_operations final status update
const emitSpy = vi.spyOn(eventBus, 'emit')
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ invoice_id: INVOICE_ID, deleted: true })
// The hard delete leaves no journal trace: the audit event carries the
// EXPLICIT actor (the MCP path runs on a service client, auth.uid() null).
expect(emitSpy).toHaveBeenCalledWith({
type: 'invoice.draft_deleted',
payload: { invoiceId: INVOICE_ID, companyId: 'company-1', userId: 'user-1' },
})
})
it('cancels a numbered draft (makulering), retaining the F-series number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
enqueue({ data: invoiceRow({ invoice_number: 'F-2026042' }) }) // fetch
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices: update().select('id')
enqueue({ data: null }) // pending_operations final status update
const emitSpy = vi.spyOn(eventBus, 'emit')
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
// The pin matches the current number: the approved makulering proceeds.
makePendingOp({ invoice_id: INVOICE_ID, expected_invoice_number: 'F-2026042' }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
invoice_id: INVOICE_ID,
cancelled: true,
invoice_number: 'F-2026042',
})
// Makulering leaves its trail in the invoice row: no delete event.
expect(emitSpy).not.toHaveBeenCalled()
})
it('auto-rejects (409) when an unnumbered draft was finalized after staging (outcome pin)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
// Still a draft, but it gained an F-number since staging: the approved
// outcome was a hard delete, so the executor must refuse, not makulera.
enqueue({ data: invoiceRow({ invoice_number: 'F-2026099' }) }) // fetch
enqueue({ data: null }) // pending_operations rejected status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, expected_invoice_number: null }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
expect(result.code).toBe('INVOICE_CANCEL_RACE')
expect(result.error).toMatch(/F-2026099/)
expect(result.error).toMatch(/stage the deletion again/i)
})
it('auto-rejects (409) when the invoice left draft between staging and approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
enqueue({ data: invoiceRow({ status: 'sent', invoice_number: 'F-2026042' }) }) // fetch
enqueue({ data: null }) // pending_operations rejected status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
expect(result.code).toBe('INVOICE_DELETE_NOT_DRAFT')
expect(result.error).toMatch(/status: sent/)
})
it('auto-rejects (409) on the TOCTOU race: draft finalized during the delete', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
enqueue({ data: invoiceRow() }) // fetch: unnumbered draft
enqueue({ data: [] }) // delete matched 0 rows (finalized concurrently)
enqueue({ data: null }) // pending_operations rejected status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
expect(result.code).toBe('INVOICE_CANCEL_RACE')
})
it('auto-rejects (404) when the invoice no longer exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
enqueue({ data: null, error: { message: 'not found' } }) // fetch fails
enqueue({ data: null }) // pending_operations rejected status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(404)
expect(result.code).toBe('INVOICE_NOT_FOUND')
})
it('fails (400) when the staged params carry no invoice_id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-delete-1' } }) // claim
enqueue({ data: null }) // pending_operations failed status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
})
+73
View File
@@ -166,6 +166,7 @@ import {
type InvoiceWriteInput,
type InvoiceWriteItemInput,
} from '@/lib/invoices/build-invoice-write'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
@@ -4640,6 +4641,75 @@ async function commitCreditInvoice(
return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } }
}
/**
* delete_draft_invoice: remove a DRAFT customer invoice via the shared
* deleteDraftInvoice service (also behind the cookie and v1 DELETE routes).
* Unnumbered draft: hard delete + invoice.draft_deleted audit event.
* Numbered draft: makulering (status 'cancelled', F-series number retained).
* The service re-validates status at commit time with TOCTOU write guards,
* so a draft that was sent between staging and approval is refused (409 ->
* auto-reject), never cancelled. expected_invoice_number (staged alongside
* invoice_id) additionally pins the approved OUTCOME: an unnumbered draft
* that was finalized between staging and approval is refused too, instead of
* silently switching from the approved hard delete to a makulering.
*/
async function commitDeleteDraftInvoice(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const invoiceId = params.invoice_id as string
if (!invoiceId) return { error: 'invoice_id is required', status: 400 }
const result = await deleteDraftInvoice({
supabase,
companyId,
userId,
invoiceId,
// Only pin when the staging tool recorded an expectation; absent means an
// op staged before the pin existed, which keeps legacy semantics.
...('expected_invoice_number' in params
? { expectedInvoiceNumber: params.expected_invoice_number as string | null }
: {}),
})
if (!result.ok) {
switch (result.code) {
case 'INVOICE_NOT_FOUND':
return { error: 'Invoice not found', errorCode: 'INVOICE_NOT_FOUND', status: 404 }
case 'INVOICE_DELETE_NOT_DRAFT':
return {
error: `Only draft invoices can be deleted (status: ${result.currentStatus}). Issued invoices are immutable: use gnubok_credit_invoice instead.`,
errorCode: 'INVOICE_DELETE_NOT_DRAFT',
status: 409,
}
case 'INVOICE_CANCEL_RACE':
return {
error:
result.currentInvoiceNumber != null
? `Invoice changed after staging: the draft was finalized and now carries number ${result.currentInvoiceNumber}, so the approved hard delete no longer applies. Stage the deletion again to makulera it instead.`
: 'Invoice was finalized or modified concurrently and could not be removed. Re-read it and stage again if it is still a draft.',
errorCode: 'INVOICE_CANCEL_RACE',
status: 409,
}
case 'INVOICE_DELETE_FAILED':
return {
error: `Failed to remove draft invoice: ${result.cause.message}`,
errorCode: 'INVOICE_DELETE_FAILED',
status: 500,
}
}
}
return {
data:
result.outcome === 'deleted'
? { invoice_id: invoiceId, deleted: true }
: { invoice_id: invoiceId, cancelled: true, invoice_number: result.invoiceNumber },
}
}
async function commitConvertInvoice(
supabase: SupabaseClient,
userId: string,
@@ -6620,6 +6690,9 @@ async function commitPendingOperationInner(
case 'credit_invoice':
result = await commitCreditInvoice(supabase, userId, companyId, pendingOp.params)
break
case 'delete_draft_invoice':
result = await commitDeleteDraftInvoice(supabase, userId, companyId, pendingOp.params)
break
case 'import_sie':
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -133,6 +133,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
create_supplier_invoice_from_inbox: 'medium',
credit_invoice: 'high',
convert_invoice: 'medium',
// Removes a DRAFT (never a posted invoice): no booking impact, but both
// outcomes are irreversible: an unnumbered draft is hard-deleted (row gone)
// and a numbered draft is makulerad, permanently consuming its F-series
// number. 'high' so a destructive delete is never auto-committed.
delete_draft_invoice: 'high',
// ── Phase 4: arbitrary-line bookkeeping primitives ─────────────────
// Both accept caller-supplied account/amount/period: unlike