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:
co-authored by
Claude Fable 5
parent
32cb8a22ce
commit
9f8fa1b692
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user