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
@@ -129,6 +129,50 @@ describe('delete_last_voucher.pg — RPC + immutability trigger interaction', ()
}
})
it('clears journal_entry_id on attached documents and deletes the voucher', async () => {
// Regression for the document-immutability triggers ignoring the
// gnubok.allow_delete bypass. delete_last_voucher unlinks documents
// (UPDATE document_attachments SET journal_entry_id = NULL) before
// deleting the entry; if the trigger refused the unlink the whole RPC
// would fail and the entry would remain.
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertPostedEntryWithLines({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, journal_entry_id)
VALUES ($1, $2, $3, $4, 'underlag.pdf', 1024, 'application/pdf', $5, $6)`,
[
docId,
userId,
companyId,
`documents/${userId}/${docId}.pdf`,
'a'.repeat(64),
entryId,
],
)
await withUserContext(userId, async (client) => {
await client.query(
`SELECT public.delete_last_voucher($1::uuid, $2::uuid)`,
[companyId, entryId],
)
const entryAfter = await client.query(
`SELECT 1 FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(entryAfter.rowCount).toBe(0)
const docAfter = await client.query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(docAfter.rows[0]!.journal_entry_id).toBeNull()
})
})
it('blocks reversed → posted UPDATE without the bypass flag', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertPostedEntryWithLines({
@@ -0,0 +1,266 @@
import { describe, it, expect } from 'vitest'
import {
getVatRate,
generateSalesVatLines,
generateReverseChargeLines,
generateInputVatLine,
extractNetAmount,
extractVatAmount,
} from '../vat-entries'
describe('getVatRate', () => {
it('returns 0.25 for standard_25', () => {
expect(getVatRate('standard_25')).toBe(0.25)
})
it('returns 0.12 for reduced_12', () => {
expect(getVatRate('reduced_12')).toBe(0.12)
})
it('returns 0.06 for reduced_6', () => {
expect(getVatRate('reduced_6')).toBe(0.06)
})
it('returns 0 for reverse_charge', () => {
expect(getVatRate('reverse_charge')).toBe(0)
})
it('returns 0 for export', () => {
expect(getVatRate('export')).toBe(0)
})
it('returns 0 for exempt', () => {
expect(getVatRate('exempt')).toBe(0)
})
})
describe('generateSalesVatLines', () => {
it('credits 2611 (Utgående moms 25%) at standard rate', () => {
const lines = generateSalesVatLines({
vatTreatment: 'standard_25',
baseAmount: 1000,
direction: 'sales',
})
expect(lines).toHaveLength(1)
expect(lines[0].account_number).toBe('2611')
expect(lines[0].debit_amount).toBe(0)
expect(lines[0].credit_amount).toBe(250)
})
it('credits 2621 (Utgående moms 12%) at reduced rate', () => {
const lines = generateSalesVatLines({
vatTreatment: 'reduced_12',
baseAmount: 1000,
direction: 'sales',
})
expect(lines).toHaveLength(1)
expect(lines[0].account_number).toBe('2621')
expect(lines[0].credit_amount).toBe(120)
})
it('credits 2631 (Utgående moms 6%) at reduced rate', () => {
const lines = generateSalesVatLines({
vatTreatment: 'reduced_6',
baseAmount: 1000,
direction: 'sales',
})
expect(lines).toHaveLength(1)
expect(lines[0].account_number).toBe('2631')
expect(lines[0].credit_amount).toBe(60)
})
it('returns empty array for reverse_charge (no domestic VAT line)', () => {
expect(
generateSalesVatLines({
vatTreatment: 'reverse_charge',
baseAmount: 1000,
direction: 'sales',
})
).toEqual([])
})
it('returns empty array for export', () => {
expect(
generateSalesVatLines({
vatTreatment: 'export',
baseAmount: 1000,
direction: 'sales',
})
).toEqual([])
})
it('returns empty array for exempt', () => {
expect(
generateSalesVatLines({
vatTreatment: 'exempt',
baseAmount: 1000,
direction: 'sales',
})
).toEqual([])
})
it('rounds VAT to 2 decimals (333.33 * 0.25 = 83.3325 → 83.33)', () => {
const lines = generateSalesVatLines({
vatTreatment: 'standard_25',
baseAmount: 333.33,
direction: 'sales',
})
expect(lines[0].credit_amount).toBe(83.33)
})
})
describe('generateReverseChargeLines — EU/non-EU (isDomestic=false)', () => {
it('debits 2645 and credits 2614 at 25%', () => {
const lines = generateReverseChargeLines(1000, 0.25, false)
expect(lines).toHaveLength(2)
expect(lines[0].account_number).toBe('2645')
expect(lines[0].debit_amount).toBe(250)
expect(lines[0].credit_amount).toBe(0)
expect(lines[1].account_number).toBe('2614')
expect(lines[1].debit_amount).toBe(0)
expect(lines[1].credit_amount).toBe(250)
})
it('debits 2645 and credits 2624 at 12%', () => {
const lines = generateReverseChargeLines(1000, 0.12, false)
expect(lines[0].account_number).toBe('2645')
expect(lines[0].debit_amount).toBe(120)
expect(lines[1].account_number).toBe('2624')
expect(lines[1].credit_amount).toBe(120)
})
it('debits 2645 and credits 2634 at 6%', () => {
const lines = generateReverseChargeLines(1000, 0.06, false)
expect(lines[0].account_number).toBe('2645')
expect(lines[0].debit_amount).toBe(60)
expect(lines[1].account_number).toBe('2634')
expect(lines[1].credit_amount).toBe(60)
})
})
describe('generateReverseChargeLines — domestic (isDomestic=true, ML 16 kap)', () => {
it('debits 2647 (not 2645) and credits 2614 at 25%', () => {
const lines = generateReverseChargeLines(1000, 0.25, true)
expect(lines).toHaveLength(2)
expect(lines[0].account_number).toBe('2647')
expect(lines[0].debit_amount).toBe(250)
expect(lines[1].account_number).toBe('2614')
expect(lines[1].credit_amount).toBe(250)
})
it('debits 2647 and credits 2624 at 12%', () => {
const lines = generateReverseChargeLines(1000, 0.12, true)
expect(lines[0].account_number).toBe('2647')
expect(lines[0].debit_amount).toBe(120)
expect(lines[1].account_number).toBe('2624')
expect(lines[1].credit_amount).toBe(120)
})
it('debits 2647 and credits 2634 at 6%', () => {
const lines = generateReverseChargeLines(1000, 0.06, true)
expect(lines[0].account_number).toBe('2647')
expect(lines[0].debit_amount).toBe(60)
expect(lines[1].account_number).toBe('2634')
expect(lines[1].credit_amount).toBe(60)
})
})
describe('generateReverseChargeLines — defaults & invariants', () => {
it('defaults to vatRate=0.25 and isDomestic=false when omitted', () => {
const lines = generateReverseChargeLines(1000)
expect(lines[0].account_number).toBe('2645')
expect(lines[1].account_number).toBe('2614')
expect(lines[0].debit_amount).toBe(250)
expect(lines[1].credit_amount).toBe(250)
})
it('keeps debit-credit pair balanced for every rate × isDomestic combination', () => {
for (const rate of [0.25, 0.12, 0.06]) {
for (const isDomestic of [true, false]) {
const lines = generateReverseChargeLines(1000, rate, isDomestic)
expect(lines[0].debit_amount).toBe(lines[1].credit_amount)
expect(lines[0].credit_amount).toBe(0)
expect(lines[1].debit_amount).toBe(0)
}
}
})
})
describe('generateInputVatLine', () => {
it('debits 2641 with VAT extracted from gross at 25% (1250 → 250)', () => {
const line = generateInputVatLine(1250, 0.25)
expect(line).not.toBeNull()
expect(line!.account_number).toBe('2641')
expect(line!.debit_amount).toBe(250)
expect(line!.credit_amount).toBe(0)
})
it('debits 2641 at 12% (1120 → 120)', () => {
const line = generateInputVatLine(1120, 0.12)
expect(line!.account_number).toBe('2641')
expect(line!.debit_amount).toBe(120)
})
it('debits 2641 at 6% (1060 → 60)', () => {
const line = generateInputVatLine(1060, 0.06)
expect(line!.account_number).toBe('2641')
expect(line!.debit_amount).toBe(60)
})
it('returns null at zero rate (export/exempt/reverse_charge purchases)', () => {
expect(generateInputVatLine(1000, 0)).toBeNull()
})
it('defaults to vatRate=0.25 when omitted', () => {
const line = generateInputVatLine(1250)
expect(line!.debit_amount).toBe(250)
})
})
describe('extractNetAmount', () => {
it('extracts 1000 net from 1250 gross at 25%', () => {
expect(extractNetAmount(1250, 0.25)).toBe(1000)
})
it('extracts 1000 net from 1120 gross at 12%', () => {
expect(extractNetAmount(1120, 0.12)).toBe(1000)
})
it('extracts 1000 net from 1060 gross at 6%', () => {
expect(extractNetAmount(1060, 0.06)).toBe(1000)
})
it('returns total unchanged at zero rate', () => {
expect(extractNetAmount(1000, 0)).toBe(1000)
})
})
describe('extractVatAmount', () => {
it('extracts 250 VAT from 1250 gross at 25%', () => {
expect(extractVatAmount(1250, 0.25)).toBe(250)
})
it('extracts 120 VAT from 1120 gross at 12%', () => {
expect(extractVatAmount(1120, 0.12)).toBe(120)
})
it('extracts 60 VAT from 1060 gross at 6%', () => {
expect(extractVatAmount(1060, 0.06)).toBe(60)
})
it('returns 0 at zero rate', () => {
expect(extractVatAmount(1000, 0)).toBe(0)
})
})
describe('extractNetAmount + extractVatAmount round-trip', () => {
it.each([
[1250, 0.25],
[1120, 0.12],
[1060, 0.06],
])('reconstructs total %s from net + vat at rate %s', (total, rate) => {
const net = extractNetAmount(total, rate)
const vat = extractVatAmount(total, rate)
expect(net + vat).toBe(total)
})
})
@@ -0,0 +1,233 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import {
insertBalancedLines,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
// BFL retention is enforced by two independent triggers on document_attachments:
// * enforce_document_journal_entry_immutability (20260506130000) — blocks
// any change to journal_entry_id once it has been set, regardless of the
// linked entry's status. Honors gnubok.allow_delete (20260506140000).
// * enforce_document_metadata_immutability (extended in 20260506120000) —
// blocks metadata changes and journal_entry_line_id changes when the
// linked entry is posted/reversed. Also honors gnubok.allow_delete.
//
// Both wordings — "BFL 5 kap" (entry trigger) and "BFL 7 kap" (metadata
// trigger) — are accepted; which one fires first depends on what column the
// UPDATE touches.
const BFL_RETENTION_ERROR = /BFL [57] kap/i
async function insertDocument(params: {
userId: string
companyId: string
journalEntryId: string | null
journalEntryLineId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, journal_entry_id, journal_entry_line_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
id,
params.userId,
params.companyId,
`documents/${params.userId}/${id}.pdf`,
'underlag.pdf',
1024,
'application/pdf',
'a'.repeat(64),
params.journalEntryId,
params.journalEntryLineId ?? null,
],
)
return id
}
// Insert a draft, balance it, and walk through the legal state-machine
// transitions to land on the requested status. enforce_journal_entry_immutability
// only allows draft→posted and posted→reversed, so the path matters.
async function insertEntryAtStatus(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
status?: 'posted' | 'reversed'
}): Promise<string> {
const entryId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
voucherNumber: params.voucherNumber,
})
await insertBalancedLines(entryId)
await getPool().query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`,
[entryId],
)
if (params.status === 'reversed') {
await getPool().query(
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
[entryId],
)
}
return entryId
}
describe('document-immutability.pg — BFL retention bypass guards', () => {
it('rejects unlinking (journal_entry_id → NULL) on a doc linked to a posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('rejects unlinking on a doc linked to a reversed entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1, status: 'reversed',
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('rejects unlinking on a doc linked to a draft entry — link is durable from first set', async () => {
// The entry-level trigger does not consult journal_entries.status; once
// journal_entry_id is set on a document, it cannot be cleared. This is
// stricter than the original branch design and matches main's intent
// that the verifikation→underlag link be durable from first set.
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const draftId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 0,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: draftId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('rejects re-pointing journal_entry_id to a different posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const entryB = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 2,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryA })
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
[entryB, docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('allows first-time linking (NULL → UUID) — legitimate linkToJournalEntry path', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: null })
await getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
[entryId, docId],
)
const after = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.journal_entry_id).toBe(entryId)
})
it('rejects unlinking journal_entry_line_id on a doc linked to a posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const lineRow = await getPool().query<{ id: string }>(
`SELECT id FROM public.journal_entry_lines WHERE journal_entry_id = $1 LIMIT 1`,
[entryId],
)
const lineId = lineRow.rows[0]!.id
const docId = await insertDocument({
userId, companyId, journalEntryId: entryId, journalEntryLineId: lineId,
})
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_line_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('end-to-end: unlink-then-delete attack is blocked at the unlink step', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await expect(
getPool().query(`DELETE FROM public.document_attachments WHERE id = $1`, [docId]),
).rejects.toThrow(/Bokföringslagen/i)
})
it('respects gnubok.allow_delete bypass — delete_last_voucher RPC keeps working', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_delete', 'true', true)`)
await client.query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
)
const after = await client.query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.journal_entry_id).toBeNull()
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+25
View File
@@ -433,6 +433,12 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'E-postleverantören kunde inte skicka meddelandet.',
message_en: 'The email provider could not deliver the message.',
},
INVOICE_SEND_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv:
'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.',
message_en: 'Failed to render invoice PDF before send; no invoice number was consumed.',
},
INVOICE_SEND_PARTIAL: {
httpStatus: 200,
message_sv:
@@ -469,6 +475,25 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Kunde inte bokföra betalningen.',
message_en: 'Failed to create payment journal entry.',
},
INVOICE_DELETE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.',
message_en: 'Only draft invoices can be deleted; non-drafts must be credited.',
remediation: {
description: 'Issue a credit note instead of deleting a posted invoice.',
},
},
INVOICE_DELETE_NUMBERED: {
httpStatus: 400,
message_sv:
'Det här utkastet har redan tilldelats ett löpnummer och kan inte tas bort. Försök skicka det igen — om sändningen lyckas behövs inget annat steg.',
message_en:
'Draft already has an invoice number assigned; refusing to delete to preserve the number sequence. Retry the send — assignment is idempotent.',
remediation: {
description:
'Retry sending the invoice; ensureInvoiceNumber is idempotent so no new number will be consumed. If sending is no longer desired, contact support to clean up the orphan number.',
},
},
}
const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {