Fix/multiple domain issue (#401)

* feat: enhance invoice management and immutability checks

- Update InvoiceDetailPage to prevent deletion of drafts with assigned invoice numbers, providing user feedback.
- Modify the invoice conversion API to ensure invoice number allocation occurs only after successful item insertion and proforma cancellation.
- Implement structured error responses for invoice deletion, ensuring only drafts without assigned numbers can be deleted.
- Add comprehensive tests for invoice deletion and conversion scenarios, including edge cases for draft invoices.
- Introduce immutability checks in the document management system to prevent unauthorized changes to linked documents.
- Create SQL migration to enforce document metadata immutability, ensuring compliance with accounting regulations.

* fix(invoice): prevent invoice number consumption on PDF render failure

* feat: add document journal entry immutability enforcement for delete_last_voucher RPC

* fix(invoice): implement rollback for orphan invoices on proforma cancel failure

* fix(document): extend immutability trigger to protect journal entry links
This commit is contained in:
Mattsson
2026-05-06 14:08:38 +02:00
committed by GitHub
parent b9a2ce522b
commit ce3af4d17e
13 changed files with 1159 additions and 61 deletions
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
describe('DELETE /api/invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 404 when invoice not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('rejects deletion of a non-draft invoice with INVOICE_DELETE_NOT_DRAFT', async () => {
enqueue({
data: { id: 'inv-1', status: 'sent', invoice_number: 'F-2026099', user_id: 'user-1' },
error: null,
})
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_DELETE_NOT_DRAFT')
})
it('rejects deletion of a draft that already has an invoice_number', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' },
error: null,
})
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { invoice_number?: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_DELETE_NUMBERED')
expect(body.error.details?.invoice_number).toBe('F-2026001')
})
it('deletes a draft with no invoice_number', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.deleted).toBe(true)
})
it('returns 500 when items delete fails', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
enqueue({ data: null, error: { message: 'items delete failed' } })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
})
})
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/convert', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const baseProforma = {
id: 'pf-1',
document_type: 'proforma',
status: 'draft',
customer_id: 'customer-1',
due_date: '2026-06-15',
currency: 'SEK',
exchange_rate: null,
exchange_rate_date: null,
subtotal: 10000,
subtotal_sek: null,
vat_amount: 2500,
vat_amount_sek: null,
total: 12500,
total_sek: null,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
reverse_charge_text: null,
your_reference: null,
our_reference: null,
notes: null,
items: [
{
sort_order: 0,
description: 'Konsultation',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 404 when proforma not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns 400 when invoice is not a proforma', async () => {
enqueue({ data: { ...baseProforma, document_type: 'invoice' }, error: null })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('does NOT advance the F-series counter when items insert fails', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
// 2. insert real invoice with null number — succeeds
enqueue({
data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' },
error: null,
})
// 3. insert items — FAILS
enqueue({ data: null, error: { message: 'items insert failed' } })
// 4. rollback delete of orphan row — succeeds
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
// Critical: counter must not have been touched.
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('rolls back the orphan invoice when proforma cancel fails', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
// 2. insert real invoice
enqueue({
data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' },
error: null,
})
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma — FAILS
enqueue({ data: null, error: { message: 'cancel failed' } })
// 5. rollback delete of orphan invoice
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toContain('cancel failed')
// Counter must not have been touched and orphan invoice must have been
// deleted (5 enqueued calls all consumed).
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('rolls back invoice + un-cancels proforma when number allocation fails', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
// 2. insert real invoice
enqueue({
data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' },
error: null,
})
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma — succeeds
enqueue({ data: null, error: null })
// 5. ensureInvoiceNumber → rpc THROWS
enqueue({ data: null, error: { message: 'number allocation failed' } })
// 6. un-cancel proforma (restore previous status)
enqueue({ data: null, error: null })
// 7. delete orphan invoice
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
})
it('allocates the F-number after items + proforma cancel succeed', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
// 2. insert real invoice
enqueue({
data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' },
error: null,
})
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma
enqueue({ data: null, error: null })
// 5. ensureInvoiceNumber → rpc returns the assigned F-number
enqueue({ data: 'F-2026005', error: null })
// 6. fetch complete invoice
enqueue({
data: { id: 'inv-1', invoice_number: 'F-2026005', items: [] },
error: null,
})
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('F-2026005')
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'generate_invoice_number',
expect.objectContaining({
p_company_id: 'company-1',
p_invoice_id: 'inv-1',
})
)
})
})
+32 -22
View File
@@ -14,6 +14,11 @@ ensureInitialized()
*
* Converts a proforma invoice to a real invoice.
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
*
* Ordering note: ensureInvoiceNumber() is the LAST side effect. The F-series
* counter only advances after items are inserted and the proforma is marked
* cancelled — so a partial failure in any earlier step rolls back the orphan
* row without leaking a number.
*/
export async function POST(
request: Request,
@@ -33,7 +38,6 @@ export async function POST(
const companyId = await requireCompanyId(supabase, user.id)
// Fetch proforma with items
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
@@ -59,9 +63,6 @@ export async function POST(
)
}
// Create the real invoice with invoice_number=null; assign atomically below.
// generate_invoice_number now requires the target row to exist so it can lock
// it (FOR UPDATE) and persist the number in the same transaction.
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
@@ -97,21 +98,6 @@ export async function POST(
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Now that the row exists, allocate the F-series number. Mutates invoice
// in place so the response includes the assigned number.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
// Roll back the partially-created invoice so the company counter is the
// only side effect to clean up (manually in worst case).
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to assign invoice number' },
{ status: 500 }
)
}
// Copy invoice items
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
@@ -133,13 +119,37 @@ export async function POST(
}
}
// Mark proforma as cancelled
await supabase
// Cancel the proforma. If this fails, the new (still unnumbered) invoice
// is an orphan — delete it so the user can retry without ending up with
// two active invoices for the same proforma. invoice_items cascade.
const previousProformaStatus = proforma.status
const { error: cancelError } = await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', id)
// Fetch complete invoice
if (cancelError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: cancelError.message }, { status: 500 })
}
// Allocate the F-series number last. If allocation fails, restore the
// proforma's previous status and delete the orphan invoice. The F-counter
// is unaffected because generate_invoice_number only commits on success.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
await supabase
.from('invoices')
.update({ status: previousProformaStatus })
.eq('id', id)
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to assign invoice number' },
{ status: 500 }
)
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
+21 -9
View File
@@ -2,13 +2,24 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api.invoices.delete')
/**
* DELETE /api/invoices/[id]
*
* Permanently deletes a draft invoice and its items.
* Only invoices with status 'draft' can be deleted — committed invoices
* are immutable per BFL and must be reversed via credit note instead.
*
* Two preconditions:
* 1. status === 'draft' — committed invoices are immutable per BFL and
* must be reversed via credit note.
* 2. invoice_number IS NULL — a draft that already holds an F-series
* number is a side effect of an interrupted send/convert/mark-sent.
* Destroying it would orphan the number and create a permanent gap
* in the verifications series. Refuse and let the user retry the
* send instead (ensureInvoiceNumber is idempotent).
*/
export async function DELETE(
request: Request,
@@ -28,10 +39,9 @@ export async function DELETE(
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice to verify ownership and status
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, user_id')
.select('id, status, invoice_number, user_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -41,13 +51,15 @@ export async function DELETE(
}
if (invoice.status !== 'draft') {
return NextResponse.json(
{ error: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.' },
{ status: 400 }
)
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
}
if (invoice.invoice_number !== null) {
return errorResponseFromCode('INVOICE_DELETE_NUMBERED', log, {
details: { invoice_number: invoice.invoice_number },
})
}
// Delete items first (FK constraint), then the invoice
const { error: itemsError } = await supabase
.from('invoice_items')
.delete()
@@ -303,6 +303,33 @@ describe('POST /api/invoices/[id]/send', () => {
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
})
it('does NOT consume an invoice number when PDF render fails (preflight)', async () => {
const draftWithoutNumber = makeInvoice({
id: 'inv-1',
status: 'draft',
invoice_number: null,
customer,
items: invoice.items,
})
enqueue({ data: draftWithoutNumber, error: null })
enqueue({ data: company, error: null })
// First render call (the preflight) throws.
mockRenderToBuffer.mockRejectedValueOnce(new Error('PDF render exploded'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PDF_RENDER_FAILED')
// Critical: counter must not have advanced.
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
// Email must not have been attempted.
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 500 when email sending fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
+31 -10
View File
@@ -63,15 +63,6 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId })
}
// Eagerly assign the invoice number — drafts get one only at send time so
// discarded drafts never consume a number.
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
opLog.error('failed to assign invoice number on send', err as Error)
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
}
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
let originalInvoiceNumber: string | undefined
@@ -88,7 +79,37 @@ export const POST = withRouteContext(
}
}
// Generate PDF — non-fatal failures here become PARTIAL after send.
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
// number. If the row is already numbered (retry path), skip — we'd just
// render twice for no gain.
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
}),
)
} catch (err) {
opLog.error('preflight PDF render failed before invoice number assignment', err as Error)
return errorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', opLog, { requestId })
}
}
// Allocate the F-series number. Idempotent — retries reuse the same number.
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
opLog.error('failed to assign invoice number on send', err as Error)
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
}
// Final render with the assigned number — this is the buffer attached to
// the email and later archived as underlag.
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,