9f8fa1b692
* 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>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
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)
|
|
})
|
|
})
|