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
@@ -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 },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user