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
+19 -75
View File
@@ -1,5 +1,4 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -7,6 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import type { InvoiceDocumentType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -27,6 +27,11 @@ ensureInitialized() // Module-level: wires the audit-log handler for invoice.dra
*
* Only drafts may be removed either way. Sent / paid invoices are immutable per
* BFL and must be reversed via a credit note instead.
*
* The fetch / guard / delete-or-cancel logic lives in
* lib/invoices/delete-draft-invoice.ts, shared with the v1 API-key route and
* the MCP delete_draft_invoice executor. This handler only maps the result to
* the cookie-session response envelope.
*/
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.delete',
@@ -34,84 +39,23 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
const { id } = await params
const opLog = log.child({ invoiceId: id })
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
}
if (invoice.status !== 'draft') {
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
}
// 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§). 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', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.is('invoice_number', null)
.select('id')
if (removeError) {
opLog.error('invoice draft delete failed', removeError)
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete: refuse rather than fall through to
// makulering of a now-issued invoice.
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
// 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: id, companyId, userId: user.id },
const result = await deleteDraftInvoice({
supabase,
companyId,
userId: user.id,
invoiceId: id,
log: opLog,
})
if (!result.ok) {
return errorResponseFromCode(result.code, opLog, { requestId })
}
if (result.outcome === 'deleted') {
return NextResponse.json({ data: { deleted: true } })
}
}
// 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 user
// 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', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (cancelError) {
opLog.error('invoice cancellation failed', cancelError)
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!updated || updated.length === 0) {
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
return NextResponse.json({ data: { cancelled: true, invoice_number: result.invoiceNumber } })
},
{ requireWrite: true },
)
@@ -30,13 +30,14 @@ vi.mock('@supabase/supabase-js', async () => {
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { PATCH as patchInvoice } from '../route'
import { eventBus } from '@/lib/events'
import { DELETE as deleteInvoice, PATCH as patchInvoice } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
type Capture = { table: string; op: 'update' | 'insert'; payload: unknown }
type Capture = { table: string; op: 'update' | 'insert' | 'delete'; payload: unknown }
/**
* Per-table result queues (arrays pop in order; single values repeat) plus a
@@ -66,6 +67,12 @@ function makeFlexibleSupabase(
return buildChain(table)
}
}
if (prop === 'delete') {
return () => {
captures.push({ table, op: 'delete', payload: undefined })
return buildChain(table)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
@@ -366,3 +373,208 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => {
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
function makeDeleteRequest(opts: { idempotencyKey?: boolean; auth?: boolean; dryRun?: boolean; id?: string } = {}) {
const headers: Record<string, string> = {}
if (opts.auth !== false) headers.Authorization = 'Bearer test-fixture-not-a-real-key'
if (opts.idempotencyKey !== false) headers['Idempotency-Key'] = 'idemdele-7777-4abc-8def-1234567890ab'
const id = opts.id ?? INVOICE_ID
const url = `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${id}${opts.dryRun ? '?dry_run=true' : ''}`
return new Request(url, { method: 'DELETE', headers })
}
describe('DELETE /api/v1/companies/:companyId/invoices/:id', () => {
it('returns 401 without a bearer token', async () => {
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await deleteInvoice(
makeDeleteRequest({ auth: false }),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 400 VALIDATION_ERROR for a non-UUID id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await deleteInvoice(
makeDeleteRequest({ id: 'not-a-uuid' }),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects a delete without an Idempotency-Key', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await deleteInvoice(
makeDeleteRequest({ idempotencyKey: false }),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns 404 when the invoice does not belong to the company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// deleteDraftInvoice fetches via .single(): a foreign or nonexistent
// id surfaces as an error result.
invoices: { data: null, error: { message: 'Row not found' } },
}),
)
const res = await deleteInvoice(makeDeleteRequest(), detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 409 INVOICE_DELETE_NOT_DRAFT for a sent invoice', async () => {
const captures: Capture[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...DRAFT_INVOICE, status: 'sent', invoice_number: '2026-0042' },
error: null,
},
},
captures,
),
)
const res = await deleteInvoice(makeDeleteRequest(), detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_DELETE_NOT_DRAFT')
expect(body.error.details.current_status).toBe('sent')
// Nothing was written to the invoice.
expect(captures.filter((c) => c.table === 'invoices')).toEqual([])
})
it('hard deletes an unnumbered draft and emits the audit event', async () => {
const captures: Capture[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null }, // fetch: draft, invoice_number null
{ data: [{ id: INVOICE_ID }], error: null }, // delete().select('id')
],
},
captures,
),
)
const emitSpy = vi.spyOn(eventBus, 'emit')
const res = await deleteInvoice(makeDeleteRequest(), detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.deleted).toBe(true)
// The event-log handler may add its own event_log insert via the same
// mocked service client: scope the assertion to the invoices table.
expect(captures.filter((c) => c.table === 'invoices')).toEqual([
{ table: 'invoices', op: 'delete', payload: undefined },
])
// The hard delete leaves no journal trace: the audit event must carry the
// EXPLICIT actor from the API-key context (auth.uid() is null here).
expect(emitSpy).toHaveBeenCalledWith({
type: 'invoice.draft_deleted',
payload: { invoiceId: INVOICE_ID, companyId: COMPANY_ID, userId: USER_ID },
})
})
it('cancels a numbered draft, retaining the F-series number', async () => {
const captures: Capture[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: { ...DRAFT_INVOICE, invoice_number: '2026-0042' }, error: null }, // fetch
{ data: [{ id: INVOICE_ID }], error: null }, // update().select('id')
],
},
captures,
),
)
const res = await deleteInvoice(makeDeleteRequest(), detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.cancelled).toBe(true)
expect(body.data.invoice_number).toBe('2026-0042')
// The row survives as makulerad: an update, never a delete.
const update = captures.find((c) => c.table === 'invoices' && c.op === 'update')
expect(update).toBeDefined()
expect(update!.payload).toMatchObject({ status: 'cancelled' })
expect(captures.filter((c) => c.op === 'delete')).toEqual([])
})
it('returns 409 INVOICE_CANCEL_RACE when the draft is finalized concurrently', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null }, // fetch: unnumbered draft
{ data: [], error: null }, // delete matched 0 rows: finalized meanwhile
],
}),
)
const res = await deleteInvoice(makeDeleteRequest(), detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CANCEL_RACE')
})
it('dry-run previews the outcome without writing', async () => {
const captures: Capture[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, invoice_number: '2026-0042' }, error: null },
},
captures,
),
)
const res = await deleteInvoice(
makeDeleteRequest({ dryRun: true }),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview).toEqual({ cancelled: true, invoice_number: '2026-0042' })
expect(captures.filter((c) => c.table === 'invoices')).toEqual([])
})
})
@@ -14,6 +14,12 @@
* invoice is not in draft status.
*
* Idempotent (mandatory Idempotency-Key) and dry-runnable.
* DELETE: remove a DRAFT invoice. Unnumbered drafts are hard deleted (no
* F-series number was consumed, so no gap arises); numbered drafts
* are makulerade (status flips to 'cancelled', the number is
* retained so the F-series stays gap-free). Non-drafts return 409
* INVOICE_DELETE_NOT_DRAFT: sent / paid invoices are immutable and
* must be reversed via POST /:id/credit instead.
*/
import { z } from 'zod'
@@ -27,6 +33,7 @@ import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/in
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { CreateInvoiceItemSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import type { Currency, Customer, InvoiceDocumentType } from '@/types'
@@ -206,7 +213,7 @@ registerEndpoint({
'Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing currency or customer: drafts are cheap to delete and recreate.',
pitfalls: [
'Idempotency-Key is mandatory.',
'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.',
'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The DELETE handler on this path uses its own code, INVOICE_DELETE_NOT_DRAFT.',
'items is a FULL REPLACE (no per-line merge): send the complete new line set, minimum one item. Omitting items keeps the current lines untouched. VAT rates are re-validated against the customer type and totals are recomputed server-side.',
'items are always built against the invoice\'s EXISTING customer: customer_id cannot change on PATCH.',
'default_dimensions replaces the entire bag (no per-key merge): read the current value first if you want to add a tag. Send {} to clear all tags. Codes are validated against the dimension registry at :send, not at PATCH time.',
@@ -530,3 +537,145 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
},
{ requireIdempotencyKey: true },
)
// ──────────────────────────────────────────────────────────────────
// DELETE: remove a DRAFT invoice (hard delete or makulering)
// ──────────────────────────────────────────────────────────────────
const InvoiceDeleteResult = z.object({
// Unnumbered draft: hard deleted.
deleted: z.boolean().optional(),
// Numbered draft: makulerad; the F-series number is retained.
cancelled: z.boolean().optional(),
invoice_number: z.string().optional(),
})
registerEndpoint({
operation: 'invoices.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/invoices/:id',
summary: 'Delete a draft invoice (hard delete if unnumbered, makulering if numbered).',
description:
'Removes an invoice in draft status. An unnumbered draft (never finalized: no F-series number was consumed) is hard deleted and responds { deleted: true }; its line items cascade. A numbered draft is makulerad: the row and its number are retained, status flips to cancelled, and the response is { cancelled: true, invoice_number } so the F-series stays gap-free per ML 17 kap 24 and BFNAR 2013:2. Returns 409 INVOICE_DELETE_NOT_DRAFT for any non-draft status: sent / paid / credited invoices are immutable and must be reversed via a credit note. Requires Idempotency-Key; dry-runnable.',
useWhen:
'You created a draft by mistake, or want to discard a draft instead of sending it. Check the response shape: deleted means the row is gone, cancelled means it survives as makulerad with its number.',
doNotUseFor:
'Withdrawing a sent / paid invoice (issue a credit note via POST /:id/credit). Editing a draft (use PATCH). Cancelling recurring schedules.',
pitfalls: [
'Idempotency-Key is mandatory. A repeated DELETE with a fresh key returns 404 for a hard-deleted draft (the row is gone) and 409 INVOICE_DELETE_NOT_DRAFT for a makulerad one (status is now cancelled).',
'409 INVOICE_DELETE_NOT_DRAFT means the invoice left draft status: it is immutable and can only be reversed via a credit note.',
'409 INVOICE_CANCEL_RACE means the invoice was finalized or sent concurrently: re-read the invoice before retrying.',
'The hard-delete path emits an invoice.draft_deleted audit event; the makulering path leaves its trail in the invoice row itself.',
],
example: {
response: {
data: { cancelled: true, invoice_number: '2026-0042' },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
// 'high', matching the delete_draft_invoice pending-op tier: both outcomes
// are irreversible (row gone, or the F-series number permanently consumed).
risk: 'high',
idempotent: false,
reversible: false,
dryRunSupported: true,
response: { success: dataEnvelope(InvoiceDeleteResult) },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.delete',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
// Dry-run: pre-flight the same guards without writing, and report which
// of the two outcomes the commit would take.
if (ctx.dryRun) {
const { data: current, error: fetchErr } = await ctx.supabase
.from('invoices')
.select('id, status, invoice_number')
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!current) {
ctx.log.warn('invoices.delete: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
}
if (current.status !== 'draft') {
return v1ErrorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
status: 409,
details: { current_status: current.status },
})
}
return dryRunPreview(
current.invoice_number
? { cancelled: true, invoice_number: current.invoice_number }
: { deleted: true },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const result = await deleteDraftInvoice({
supabase: ctx.supabase,
companyId: ctx.companyId!,
// Explicit actor: the service-role client nulls auth.uid(), so the
// audit event's userId must come from the API-key context.
userId: ctx.userId,
invoiceId,
log: ctx.log,
})
if (!result.ok) {
switch (result.code) {
case 'INVOICE_NOT_FOUND':
// Generic NOT_FOUND: same shape as GET, no existence leak.
ctx.log.warn('invoices.delete: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
case 'INVOICE_DELETE_NOT_DRAFT':
// Registry maps this code to 400 for the cookie route; on v1 a
// state-machine refusal is a conflict, aligned with
// INVOICE_UPDATE_NOT_DRAFT.
return v1ErrorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
status: 409,
details: { current_status: result.currentStatus },
})
case 'INVOICE_CANCEL_RACE':
return v1ErrorResponseFromCode('INVOICE_CANCEL_RACE', ctx.log, {
requestId: ctx.requestId,
})
case 'INVOICE_DELETE_FAILED':
return v1ErrorResponseFromCode('INVOICE_DELETE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: result.cause.code },
})
}
}
if (result.outcome === 'deleted') {
return ok({ deleted: true }, { requestId: ctx.requestId })
}
return ok({ cancelled: true, invoice_number: result.invoiceNumber }, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)