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
parent 32cb8a22ce
commit 9f8fa1b692
21 changed files with 1257 additions and 83 deletions
+2
View File
@@ -1358,6 +1358,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload).
[2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason.
[2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff.
[2026-08-30] delete_draft_invoice risk tier 'high' (not 'medium' like update_invoice): both outcomes are irreversible (hard delete removes the row; makulering permanently consumes the F-series number), so the op must never be auto-committed.
[2026-08-30] v1 DELETE invoices/{id} returns INVOICE_DELETE_NOT_DRAFT as 409 via the status override (registry maps it to 400 for the cookie route): a state-machine refusal is a conflict on v1, aligned with INVOICE_UPDATE_NOT_DRAFT; web behavior left unchanged.
[2026-08-30] book_skattekonto_row(s) tier 'medium' + scope 'transactions:write': rule-driven booking with no caller-supplied lines mirrors book_mileage_period (not create_voucher's 'high'); scope follows reconcile_residual (books an outside row). Commit service gates on SKATTEVERKET_ENABLED for HTTP-dispatcher parity, recoverable so the op stays pending.
[2026-08-30] Reminder text overrides (company_settings.reminder_text_overrides, level_1..3 x subject/body): the defaults are expressed as placeholder patterns (REMINDER_EMAIL_DEFAULT_TEXTS) and BOTH the stock mail and overrides render through the same substitution pipeline (applyPlaceholders + escape per output variant), so the settings-UI prefill is byte-for-byte the mail that goes out and cannot drift; this differs from the invoice_email_texts precedent, whose hand-written pattern forms can drift from the coded defaults. The level-3 default body is now an explicit inkassovarning (8 days, fordran till inkasso, costs per lag (1981:739)) but the level TITLE stays 'Slutlig paminnelse': the title is reused as the level name in settings labels and subject prefix, and renaming it everywhere is wording churn beyond the ask. An overridden subject owns the whole line (no automatic ' (inkl. drojsmalsranta)' suffix; {belopp} already includes surcharges), the stock subject keeps the suffix byte-identically. No pg test for the migration: a declarative CHECK (jsonb_typeof object) identical in shape to invoice_email_texts (20260703091000), which also shipped without one. The v1 REST/MCP update_company_settings surface was NOT extended: it is a curated field set with staged operations and its own placeholder refinement, a separate parity slice. typecheck/antipattern baselines deliberately not ratcheted in this diff: both one-count drops predate the branch (main drift), gates only fail on increase.
[2026-08-30] PR #2021 round 2 (#546): the relayed Peppol buyer restriction now says the customer's org number must not be a personnummer (prepareParty('buyer') in lib/invoices/peppol-bis-billing.ts refuses it with BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED, so an enskild firma CUSTOMER is refused, not only an enskild firma sender), Step 4 of the invoicing-rules workflow points at the Peppol section so a top-down reader never reaches the external-provider fallback first, the mark-sent recovery is scoped to the still-draft invoice in every text (INVOICE_MARK_SENT_REPAIR_REQUIRED leaves the invoice sent with the verifikat posted and a second mark-sent returns 409; the reviewer's proposed repair tool gnubok_link_invoice_to_voucher is the PAYMENT link and requires status sent/overdue/partially_paid, so no tool is named and the repair is left to support), and the verifikat parenthetical says "under faktureringsmetoden" (kontantmetod and defer_invoice_booking companies get none at issue). The guard test now also pins the two v1 route descriptions by reading the route source (apiskill:check only detects generated-vs-source drift, not a truth regression). The atom bump was seeded as a THIRD append-only migration (20260830101500, atom v9) rather than consolidating to one: the Supabase preview branch for the PR (xxnqggttsefleehmarjo) has applied both 20260829000100 and 20260829010000 per its schema_migrations, so deleting either would leave a remote with versions absent from the repo, the orphan class the migration rule forbids; all three seeds are idempotent upserts with the version guard, so prod applying them in sequence ends at v9. The generator's max-plus-one name (20260829010001) was renamed to 20260830101500 for the same reason as round 1 (newer than every file on origin/main and every sibling worktree; skills:check hashes content, the pg replay test globs the seed).
+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 },
)
@@ -38,6 +38,7 @@ export const OPERATION_LABEL_KEYS: Record<string, string> = {
// Invoices
credit_invoice: 'type_credit_invoice',
convert_invoice: 'type_convert_invoice',
delete_draft_invoice: 'type_delete_draft_invoice',
// Documents & links
attach_document_to_transaction: 'type_attach_document_to_transaction',
link_document_to_voucher: 'type_link_document_to_voucher',
@@ -125,6 +126,7 @@ export const singleActionWarnings: Record<string, string> = {
correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).',
reverse_entry: 'Genom att klicka godkänn så stornas verifikationen: originalet behålls synligt (BFL 5 kap).',
credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.',
delete_draft_invoice: 'Genom att klicka godkänn så tas utkastet bort: onumrerade utkast raderas permanent, numrerade makuleras med bevarat fakturanummer.',
credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.',
approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.',
convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.',
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { deriveToolMeta, tools } from '../server'
const INVOICE_ID = '22222222-2222-4222-8222-222222222222'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_delete_draft_invoice')!
function draftInvoice(overrides: Record<string, unknown> = {}) {
return {
id: INVOICE_ID,
invoice_number: null,
status: 'draft',
total: 12500,
currency: 'SEK',
customer: { name: 'Testbrand AB' },
...overrides,
}
}
describe('gnubok_delete_draft_invoice: registration', () => {
it('is a strict, staged, destructive invoices:write tool at high risk', () => {
expect(tool()).toBeDefined()
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(tool().annotations.readOnlyHint).toBe(false)
expect(tool().annotations.destructiveHint).toBe(true)
expect(tool().annotations.idempotentHint).toBe(false)
// tools/list budget is at zero headroom: search-only catalog visibility.
expect(tool().catalogVisibility).toBe('search')
expect(TOOL_SCOPE_MAP.gnubok_delete_draft_invoice).toBe('invoices:write')
// 'high' risk: never auto-committed, approval is always required.
expect(OPERATION_RISK_TIERS.delete_draft_invoice).toBe('high')
})
it('returns the staged-operation envelope and derives requires_approval meta', () => {
const schema = tool().outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
// deriveToolMeta keys off the STAGED_OPERATION_SCHEMA reference: the
// machine-readable approval contract must come for free.
expect(deriveToolMeta(tool())).toMatchObject({
requires_approval: true,
approve_tool: 'gnubok_approve_pending_operation',
})
})
it('keeps its description within the 280-char budget and declares drafts-only staging', () => {
expect(tool().description.length).toBeLessThanOrEqual(280)
expect(tool().description).toMatch(/stag(e|ing)/i)
expect(tool().description).toMatch(/draft/i)
expect(tool().description).toContain('gnubok_credit_invoice')
})
})
describe('gnubok_delete_draft_invoice: staging', () => {
it('requires invoice_id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute({}, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/invoice_id is required/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('throws for an invoice that does not exist in the company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'not found' } })
await expect(
tool().execute({ invoice_id: INVOICE_ID }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/not found/i)
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
})
it.each([
['sent invoice', 'sent'],
['paid invoice', 'paid'],
['credited invoice', 'credited'],
['already cancelled invoice', 'cancelled'],
])('refuses a %s at staging time, before anything is staged', async (_label, status) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice({ status, invoice_number: 'F-2026042' }) })
await expect(
tool().execute({ invoice_id: INVOICE_ID }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/only draft invoices can be deleted/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
})
it('stages a hard delete for an unnumbered draft, requiring approval', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: draftInvoice() })
enqueue({ data: { id: 'op-delete-1' } })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
expect(result).toMatchObject({
staged: true,
operation_id: 'op-delete-1',
risk_level: 'high',
})
expect(supabase.from).toHaveBeenCalledTimes(2)
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
expect(result.preview).toMatchObject({
invoice_id: INVOICE_ID,
invoice_number: null,
customer_name: 'Testbrand AB',
})
expect(String(result.preview.method)).toMatch(/hard delete/i)
// The staged params pin the approved outcome: null = hard delete.
const stagedRow = findCall('pending_operations', 'insert')?.[0] as
| { params?: Record<string, unknown> }
| undefined
expect(stagedRow?.params).toMatchObject({
invoice_id: INVOICE_ID,
expected_invoice_number: null,
})
})
it('stages a makulering for a numbered draft, retaining the F-series number', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: draftInvoice({ invoice_number: 'F-2026042' }) })
enqueue({ data: { id: 'op-delete-2' } })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result).toMatchObject({ staged: true, risk_level: 'high' })
expect(result.preview).toMatchObject({ invoice_number: 'F-2026042' })
expect(String(result.preview.method)).toMatch(/makulering/i)
// The pin records the number whose makulering was approved.
const stagedRow = findCall('pending_operations', 'insert')?.[0] as
| { params?: Record<string, unknown> }
| undefined
expect(stagedRow?.params).toMatchObject({ expected_invoice_number: 'F-2026042' })
})
it('returns a dry-run preview without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice({ invoice_number: 'F-2026042' }) })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID, dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview).toMatchObject({ invoice_number: 'F-2026042' })
// Exactly one read; pending_operations was never touched.
expect(supabase.from).toHaveBeenCalledTimes(1)
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
})
})
+74
View File
@@ -17336,6 +17336,80 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_delete_draft_invoice',
title: 'Delete Draft Invoice',
description:
'Stage removal of a DRAFT invoice for approval. Drafts only: an unnumbered draft is hard deleted; a numbered draft is makulerad (status cancelled, number kept so the F-series stays gap-free). Sent/paid/credited invoices are refused: use gnubok_credit_invoice instead.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'UUID of the draft invoice, from gnubok_list_invoices.' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' },
idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' },
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required. Use gnubok_list_invoices to find IDs.')
const { data: inv } = await supabase
.from('invoices')
.select('id, invoice_number, status, total, currency, customer:customers(name)')
.eq('id', invoiceId).eq('company_id', companyId).single()
if (!inv) throw new Error('Invoice not found')
if (inv.status !== 'draft') {
throw new Error(
`Only draft invoices can be deleted (status: ${inv.status}). ` +
'Issued invoices are immutable per BFL: use gnubok_credit_invoice instead.'
)
}
// Unnumbered draft = hard delete (no F-series number was consumed, so
// no gap arises); numbered draft = makulering (number retained so the
// F-series stays gap-free). The commit executor re-validates status
// with TOCTOU write guards, so a draft sent between staging and
// approval is refused, never cancelled.
const willHardDelete = !inv.invoice_number
return stagePendingOperation(supabase, companyId, userId, 'delete_draft_invoice',
willHardDelete
? 'Ta bort fakturautkast (onumrerat)'
: `Makulera fakturautkast ${inv.invoice_number}`,
// expected_invoice_number pins the APPROVED outcome: if the draft is
// finalized (gains a number) between staging and approval, the
// executor refuses instead of silently makulera what the approver saw
// as a hard delete.
{ invoice_id: invoiceId, expected_invoice_number: inv.invoice_number ?? null },
{
invoice_id: inv.id,
invoice_number: inv.invoice_number ?? null,
customer_name: (inv.customer as { name?: string } | null)?.name ?? null,
total: inv.total,
currency: inv.currency,
method: willHardDelete
? 'hard delete: the unnumbered draft and its lines are removed; no F-series number was consumed, so no gap arises'
: 'makulering: status flips to cancelled and the invoice number is retained, keeping the F-series gap-free',
},
actor,
{
description: 'After approval the draft is removed (unnumbered) or makulerad (numbered). Both outcomes are permanent.',
tool: 'gnubok_list_invoices',
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
{
name: 'gnubok_create_sie_upload',
keywords: ['sie', 'sie-fil', 'importera bokföring'],
@@ -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
+1
View File
@@ -702,6 +702,7 @@
"type_credit_supplier_invoice": "Supplier credit note",
"type_credit_invoice": "Credit invoice",
"type_convert_invoice": "Convert invoice",
"type_delete_draft_invoice": "Delete draft invoice",
"type_attach_document_to_transaction": "Attach document",
"type_link_document_to_voucher": "Link document",
"type_link_documents_to_vouchers": "Link documents (bulk)",
+1
View File
@@ -702,6 +702,7 @@
"type_credit_supplier_invoice": "Leverantörskreditfaktura",
"type_credit_invoice": "Kreditfaktura",
"type_convert_invoice": "Konvertera faktura",
"type_delete_draft_invoice": "Ta bort fakturautkast",
"type_attach_document_to_transaction": "Bifoga underlag",
"type_link_document_to_voucher": "Länka underlag",
"type_link_documents_to_vouchers": "Länka underlag (bulk)",
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 139 endpoints.
pagination, scopes), and all 140 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 139 operations. Paths are shown without
API version `2026-05-12`, 140 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (5)
@@ -192,7 +192,7 @@ POST /companies/{companyId}/fiscal-periods/{id}/year-end : Execute year-end clos
GET /companies/{companyId}/skatteverket/vat-declarations : Read a filed momsdeklaration (submitted and/or decided) from Skatteverket [scope:compliance:read risk:low idempotent]
```
### Invoices (AR) (10)
### Invoices (AR) (11)
Full detail: [references/invoices.md](references/invoices.md)
@@ -201,6 +201,7 @@ GET /companies/{companyId}/invoices : List invoices for a company [scope:invoice
POST /companies/{companyId}/invoices : Create a draft invoice, proforma, or delivery note [scope:invoices:write risk:medium idempotent dry-run reversible]
GET /companies/{companyId}/invoices/{id} : Retrieve a single invoice by id [scope:invoices:read risk:low idempotent]
PATCH /companies/{companyId}/invoices/{id} : Update a draft invoice (metadata fields, optionally replacing line items) [scope:invoices:write risk:low idempotent dry-run reversible]
DELETE /companies/{companyId}/invoices/{id} : Delete a draft invoice (hard delete if unnumbered, makulering if numbered) [scope:invoices:write risk:high dry-run]
POST /companies/{companyId}/invoices/{id}/credit : Issue a credit note (kreditfaktura) against an invoice [scope:invoices:write risk:high idempotent dry-run]
POST /companies/{companyId}/invoices/{id}/mark-paid : Record a payment against an invoice [scope:invoices:write risk:medium idempotent dry-run]
POST /companies/{companyId}/invoices/{id}/mark-sent : Transition a draft invoice to sent (without emailing) [scope:invoices:write risk:medium idempotent dry-run]
+52 -1
View File
@@ -285,7 +285,7 @@ Partial update for invoices in draft status. Allowed fields: invoice_date, due_d
**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.
@@ -362,6 +362,57 @@ Example response `200`:
---
### `DELETE /api/v1/companies/{companyId}/invoices/{id}`
**Delete a draft invoice (hard delete if unnumbered, makulering if numbered).**
`scope:invoices:write · risk:high · dry-run`
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.
**Use when:** 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.
**Do not use for:** 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { deleted?: boolean, cancelled?: boolean, invoice_number?: string },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"cancelled": true,
"invoice_number": "2026-0042"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/credit`
**Issue a credit note (kreditfaktura) against an invoice.**
@@ -0,0 +1,105 @@
-- Add 'delete_draft_invoice' to the pending_operations operation_type CHECK
-- constraint.
--
-- gnubok_delete_draft_invoice (MCP) stages removal of a DRAFT customer
-- invoice. The user approves it in Granskning and commitDeleteDraftInvoice in
-- lib/pending-operations/commit.ts delegates to the shared
-- lib/invoices/delete-draft-invoice.ts service (the same code behind the
-- cookie-session and v1 DELETE routes): an unnumbered draft is hard deleted
-- (no F-series number was consumed, so no gap arises; ML 17 kap 24) and a
-- numbered draft is makulerad (status 'cancelled', number retained so the
-- F-series stays gap-free per BFNAR 2013:2). Non-drafts are refused at both
-- staging and commit time; posted invoices can only be reversed via a credit
-- note. Risk 'high': both outcomes are irreversible (row gone, or the number
-- permanently consumed), so the op is never auto-committed.
--
-- NOTE on the value list: this constraint is re-created wholesale (the
-- established pattern here), so the list below is every value of the
-- constraint as left by 20260830130000 (which added book_skattekonto_row /
-- book_skattekonto_rows) PLUS the new value. Dropping any existing value
-- here would silently revoke it.
--
-- NOT VALID + separate VALIDATE migration (paired file, same pattern as
-- 20260830130000 / 20260830130001).
--
-- pg-test: tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every
-- op type staged in server.ts or tiered in risk-tiers.ts is accepted here.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'post_kontantmetod_cutoff',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'delete_draft_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'set_run_salary',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings',
'update_customer',
'update_invoice',
'create_recurring_schedule',
'update_recurring_schedule',
'log_mileage_trip',
'book_mileage_period',
'link_documents_to_vouchers',
'reconciliation_match',
'reconciliation_unmatch',
'reconciliation_signoff',
'reconciliation_residual',
'book_skattekonto_row',
'book_skattekonto_rows'
)) NOT VALID;
@@ -0,0 +1,6 @@
-- Validate the operation type CHECK re-added in 20260830150000.
-- This separate transaction avoids a full-table scan while the preceding
-- migration holds its stronger table lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
+3
View File
@@ -2522,6 +2522,9 @@ export type PendingOperationType =
// Stream 1 Phase 1: invoice operations beyond simple create/send
| 'credit_invoice'
| 'convert_invoice'
// Draft-only invoice removal: unnumbered drafts hard delete, numbered
// drafts are makulerade (number retained). Non-drafts are refused.
| 'delete_draft_invoice'
// Draft-only invoice edit (items full-replace); sent/booked stays immutable,
// correction is a kreditfaktura.
| 'update_invoice'