* 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>
209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
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'
|
|
|
|
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
|
|
|
|
/**
|
|
* DELETE /api/invoices/[id]
|
|
*
|
|
* Removes a draft invoice. Behaviour depends on whether a 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§). 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§ / 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.
|
|
*
|
|
* 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',
|
|
async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
|
|
const { id } = await params
|
|
const opLog = log.child({ invoiceId: 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 } })
|
|
}
|
|
|
|
return NextResponse.json({ data: { cancelled: true, invoice_number: result.invoiceNumber } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
/**
|
|
* PATCH /api/invoices/[id]
|
|
*
|
|
* Edit a DRAFT invoice (or proforma / delivery note) in place: header fields
|
|
* AND line items. Only drafts are editable: a journal entry (verifikat) is
|
|
* created when an invoice is sent (mark-sent / send) or, for kontantmetoden, at
|
|
* payment, so a draft has no committed entry and BFL immutability (guard rail #1)
|
|
* does not yet apply. Sent / paid / cancelled / credited invoices are immutable
|
|
* and must be reversed via a credit note instead.
|
|
*
|
|
* The invoice's number and status are preserved: editing never (re)allocates a
|
|
* number nor changes lifecycle state, and never emits invoice.created (numbered
|
|
* drafts already emitted it at create; unnumbered ones emit on finalize). The
|
|
* validation + computation is shared with POST /api/invoices via
|
|
* buildInvoiceWriteData so VAT rules, ROT/RUT, accruals and totals stay identical.
|
|
*/
|
|
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'invoice.update',
|
|
async (request, { supabase, companyId, log: ctxLog, requestId }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const validation = await validateBody(request, UpdateInvoiceSchema, {
|
|
log: ctxLog,
|
|
operation: 'invoice.update',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
const input = validation.data
|
|
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
|
|
|
|
// Fetch the target. Only drafts (not sent, no committed verifikat, not a
|
|
// received self-billing document) may be edited.
|
|
const { data: existing, error: fetchError } = await supabase
|
|
.from('invoices')
|
|
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, deduction_personnummer_encrypted, deduction_personnummer_last4')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId!)
|
|
.single()
|
|
|
|
if (fetchError || !existing) {
|
|
return errorResponseFromCode('INVOICE_NOT_FOUND', ctxLog, { requestId })
|
|
}
|
|
|
|
// journal_entry_id is belt-and-suspenders: a draft shouldn't carry one,
|
|
// but if some flow ever booked it, refuse the edit (the entry is immutable).
|
|
// Shared predicate (lib/invoices/is-editable-draft): the single source of
|
|
// truth the detail and edit pages also gate on, so the rule can't drift.
|
|
if (!isEditableInvoiceDraft(existing)) {
|
|
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
|
|
}
|
|
|
|
// Resolve the (possibly changed) customer.
|
|
const { data: customer, error: customerError } = await supabase
|
|
.from('customers')
|
|
.select('*')
|
|
.eq('id', input.customer_id)
|
|
.eq('company_id', companyId!)
|
|
.single()
|
|
|
|
if (customerError || !customer) {
|
|
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctxLog, {
|
|
requestId,
|
|
details: { customerId: input.customer_id },
|
|
})
|
|
}
|
|
|
|
const build = await buildInvoiceWriteData({
|
|
supabase,
|
|
companyId: companyId!,
|
|
customer,
|
|
documentType,
|
|
input,
|
|
// The stored personnummer exists only as ciphertext (client sees _last4
|
|
// at most), so an edit that leaves the field empty keeps it rather than
|
|
// failing ROT/RUT validation (issue #1175).
|
|
existingPersonnummer: existing.deduction_personnummer_encrypted
|
|
? {
|
|
encrypted: existing.deduction_personnummer_encrypted,
|
|
last4: existing.deduction_personnummer_last4 ?? null,
|
|
}
|
|
: null,
|
|
})
|
|
if (!build.ok) {
|
|
if ('dbError' in build) {
|
|
ctxLog.error('invoice write build failed on a DB lookup', build.dbError as Error)
|
|
return errorResponse(build.dbError, ctxLog, { requestId })
|
|
}
|
|
return errorResponseFromCode(build.code, ctxLog, { requestId, details: build.details })
|
|
}
|
|
|
|
// Update the draft row. invoice_number + status are intentionally NOT in
|
|
// build.invoiceFields, so they are preserved. The .eq('status','draft')
|
|
// guard turns a concurrent send/finalize into a 0-row update (race), rather
|
|
// than silently rewriting a now-issued invoice.
|
|
// Öresavrundning is display-only and optional in the update body. Persist
|
|
// it when the editor sent a value; otherwise strip it so a partial update
|
|
// can't reset a draft's stored flag (build defaults an absent flag to null).
|
|
const updateFields =
|
|
input.ore_rounding === undefined
|
|
? (() => {
|
|
const { ore_rounding: _oreRounding, ...rest } = build.invoiceFields
|
|
return rest
|
|
})()
|
|
: build.invoiceFields
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('invoices')
|
|
.update({ ...updateFields, updated_at: new Date().toISOString() })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId!)
|
|
.eq('status', 'draft')
|
|
.select('id')
|
|
|
|
if (updateError) {
|
|
ctxLog.error('invoice update failed', updateError, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctxLog, {
|
|
requestId,
|
|
details: { pgCode: updateError.code, pgMessage: getUserErrorMessage(updateError) },
|
|
})
|
|
}
|
|
if (!updated || updated.length === 0) {
|
|
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
|
|
}
|
|
|
|
// Replace line items wholesale (shared helper: the v1 REST route and the
|
|
// update_invoice commit executor use the same delete + reinsert). A draft
|
|
// has no journal entry or linked docs, so full replace is safe and lets
|
|
// the user add / remove / reorder rows freely. invoice_items cascade
|
|
// nothing else.
|
|
const replaced = await replaceInvoiceItems(supabase, id, build.items)
|
|
if (!replaced.ok) {
|
|
ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
|
|
requestId,
|
|
details: { pgCode: replaced.error.code, pgMessage: getUserErrorMessage(replaced.error) },
|
|
})
|
|
}
|
|
|
|
const { data: completeInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
return NextResponse.json({ data: completeInvoice })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|