diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 667d22ea..baba81d1 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -140,7 +140,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String), - undefined + undefined, + expect.anything() ) }) @@ -174,7 +175,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String), - 'enskild_firma' + 'enskild_firma', + expect.anything() ) }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 6f0e4bdd..a2f57ab0 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -118,7 +118,9 @@ export async function POST( const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: paymentDate, - description: `Betalning faktura ${invoice.invoice_number}`, + description: invoice.customer?.name + ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` + : `Inbetalning kundfaktura ${invoice.invoice_number}`, source_type: sourceType, source_id: invoice.id, lines: customLines, @@ -132,7 +134,8 @@ export async function POST( user.id, invoice as Invoice, paymentDate, - exchangeRateDifference + exchangeRateDifference, + invoice.customer?.name ) journalEntryId = journalEntry?.id ?? null } else { @@ -142,7 +145,8 @@ export async function POST( user.id, invoice as Invoice, paymentDate, - entityType + entityType, + invoice.customer?.name ) journalEntryId = journalEntry?.id ?? null } diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index fe501782..6a5f4978 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -74,7 +74,8 @@ export async function POST( supabase, user.id, invoice as Invoice, - (settings?.entity_type as EntityType) || 'enskild_firma' + (settings?.entity_type as EntityType) || 'enskild_firma', + invoice.customer?.name ) if (journalEntry) { journalEntryId = journalEntry.id diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 90fd3023..4724621f 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -387,7 +387,8 @@ async function createCreditNote( supabase, userId, completeCreditNote as Invoice, - entityType + entityType, + completeCreditNote.customer?.name ) if (journalEntry) { await supabase diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index bb7b5aba..43989e3a 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -111,7 +111,8 @@ export async function POST( user.id, creditNote as SupplierInvoice, creditItems as SupplierInvoiceItem[], - original.supplier?.supplier_type || 'swedish_business' + original.supplier?.supplier_type || 'swedish_business', + original.supplier?.name ) if (journalEntry) { journalEntryId = journalEntry.id diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 2efbf4f6..c9b0b184 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -72,7 +72,8 @@ export async function POST( invoice as SupplierInvoice, (invoice.items || []) as SupplierInvoiceItem[], paymentDate, - invoice.supplier?.supplier_type || 'swedish_business' + invoice.supplier?.supplier_type || 'swedish_business', + invoice.supplier?.name ) if (journalEntry) journalEntryId = journalEntry.id } else { @@ -82,7 +83,8 @@ export async function POST( invoice as SupplierInvoice, paymentAmount, paymentDate, - body.exchange_rate_difference + body.exchange_rate_difference, + invoice.supplier?.name ) if (journalEntry) journalEntryId = journalEntry.id } diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 26474f26..38648586 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -172,7 +172,8 @@ export async function POST(request: Request) { user.id, invoice as SupplierInvoice, items as SupplierInvoiceItem[], - supplier.supplier_type + supplier.supplier_type, + supplier.name ) if (journalEntry) { registrationJournalEntryId = journalEntry.id diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index b7836644..c7882785 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -190,7 +190,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect.anything(), 'user-1', expect.objectContaining({ id: VALID_UUID }), - '2024-06-15' + '2024-06-15', + undefined, + expect.anything() ) }) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 3ae526fb..06dbcfa0 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -110,7 +110,8 @@ export async function POST( user.id, invoice as Invoice, transaction.date, - entityType + entityType, + invoice.customer?.name ) journalEntryId = journalEntry?.id ?? null } else { @@ -119,7 +120,9 @@ export async function POST( supabase, user.id, invoice as Invoice, - transaction.date + transaction.date, + undefined, + invoice.customer?.name ) journalEntryId = journalEntry?.id ?? null } diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index c53804ab..0a5ed5bb 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -1,6 +1,29 @@ -import { describe, it, expect } from 'vitest' -import { validateBalance, getSwedishLocalDate } from '../engine' -import type { CreateJournalEntryLineInput } from '@/types' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine' +import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types' + +// Mock Supabase client for createDraftEntry/reverseEntry tests +function createMockChain(overrides: Record = {}) { + const chain: Record = { + select: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: overrides.singleData ?? null, error: overrides.singleError ?? null }), + eq: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + lte: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + } + return chain +} + +// Mock event bus +vi.mock('@/lib/events', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue([]) }, +})) describe('validateBalance', () => { it('balanced entry (debit == credit) → valid: true', () => { @@ -75,3 +98,69 @@ describe('getSwedishLocalDate', () => { expect(parsed.toString()).not.toBe('Invalid Date') }) }) + +describe('createDraftEntry — cancelled status on line-insert failure', () => { + it('sets status to cancelled (not delete) when line insert fails', async () => { + const updateMock = vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }) + + const supabase = { + from: vi.fn().mockImplementation((table: string) => { + if (table === 'journal_entries') { + return { + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'entry-1', user_id: 'user-1', status: 'draft' as JournalEntryStatus }, + error: null, + }), + }), + }), + update: updateMock, + delete: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }), + } + } + if (table === 'journal_entry_lines') { + return { + insert: vi.fn().mockResolvedValue({ error: { message: 'Line insert failed' } }), + } + } + if (table === 'chart_of_accounts') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockResolvedValue({ + data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], + error: null, + }), + }), + }), + } + } + return createMockChain() + }), + } + + await expect( + createDraftEntry(supabase as never, 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2024-01-01', + description: 'Test', + source_type: 'manual', + lines: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ], + }) + ).rejects.toThrow('Failed to create journal entry lines') + + // Should call update with cancelled status, NOT delete + expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' }) + }) +}) + +describe('JournalEntryStatus type includes cancelled', () => { + it('cancelled is a valid JournalEntryStatus value', () => { + const status: JournalEntryStatus = 'cancelled' + expect(['draft', 'posted', 'reversed', 'cancelled']).toContain(status) + }) +}) diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts index 582a09d2..46ab5bab 100644 --- a/lib/bookkeeping/__tests__/invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts @@ -490,6 +490,89 @@ describe('createInvoiceJournalEntry — EUR foreign currency', () => { }) }) +describe('BFL-compliant descriptions with counterparty names', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createInvoiceJournalEntry includes customer name in description', async () => { + const invoice = makeInvoice({ + items: [makeItem()], + }) + + await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma', 'Foretag AB') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kundfaktura 1001, Foretag AB') + }) + + it('createInvoiceJournalEntry falls back without customer name', async () => { + const invoice = makeInvoice({ + items: [makeItem()], + }) + + await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kundfaktura 1001') + }) + + it('createInvoicePaymentJournalEntry includes customer name', async () => { + const invoice = makeInvoice({ total: 1250 }) + + await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', undefined, 'Foretag AB') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Inbetalning kundfaktura 1001, Foretag AB') + }) + + it('createInvoicePaymentJournalEntry falls back without customer name', async () => { + const invoice = makeInvoice({ total: 1250 }) + + await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Inbetalning kundfaktura 1001') + }) + + it('createCreditNoteJournalEntry includes customer name', async () => { + const creditNote = makeInvoice({ + invoice_number: 'KR-1001', + subtotal: -1000, + vat_amount: -250, + total: -1250, + items: [makeItem({ quantity: -1, line_total: -1000, vat_amount: -250 })], + }) + + await createCreditNoteJournalEntry(null as never, 'user-1', creditNote, 'enskild_firma', 'Foretag AB') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kreditfaktura KR-1001, Foretag AB') + }) + + it('createInvoiceCashEntry includes customer name', async () => { + const invoice = makeInvoice({ + items: [makeItem()], + }) + + await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma', 'Foretag AB') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kontantbetalning kundfaktura 1001, Foretag AB') + }) + + it('createInvoiceCashEntry falls back without customer name', async () => { + const invoice = makeInvoice({ + items: [makeItem()], + }) + + await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma') + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kontantbetalning kundfaktura 1001') + }) +}) + describe('createInvoicePaymentJournalEntry — exchange rate difference', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts index 0e98501d..604e0e6e 100644 --- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts @@ -368,6 +368,36 @@ describe('createSupplierInvoiceRegistrationEntry', () => { expect(input.description).toContain('42') }) + it('description includes supplier name when provided', async () => { + const invoice = makeSupplierInvoice({ + supplier_invoice_number: 'LF-100', + arrival_number: 5, + }) + const items = [makeItem()] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'user-1', invoice, items, 'swedish_business', 'Leverantör AB' + ) + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Leverantörsfaktura LF-100, Leverantör AB (ankomst 5)') + }) + + it('description falls back without supplier name', async () => { + const invoice = makeSupplierInvoice({ + supplier_invoice_number: 'LF-100', + arrival_number: 5, + }) + const items = [makeItem()] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'user-1', invoice, items, 'swedish_business' + ) + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Leverantörsfaktura LF-100 (ankomst 5)') + }) + it('handles non-EU reverse charge (services)', async () => { const invoice = makeSupplierInvoice({ subtotal: 5000, @@ -553,6 +583,20 @@ describe('createSupplierInvoicePaymentEntry', () => { expect(input.source_id).toBe('si-pay-1') }) + it('description includes supplier name when provided', async () => { + const invoice = makeSupplierInvoice({ + supplier_invoice_number: 'LF-200', + arrival_number: 10, + }) + + await createSupplierInvoicePaymentEntry( + null as never, 'user-1', invoice, 10000, '2024-07-01', undefined, 'Leverantör AB' + ) + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)') + }) + it('uses paymentDate not invoice_date as entry_date', async () => { const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' }) @@ -705,8 +749,20 @@ describe('createSupplierInvoiceCashEntry', () => { expect(input.source_id).toBe('si-cash-1') }) - it('description contains "kontantmetoden"', async () => { - const invoice = makeSupplierInvoice() + it('description includes supplier name when provided', async () => { + const invoice = makeSupplierInvoice({ supplier_invoice_number: 'LF-300' }) + const items = [makeItem()] + + await createSupplierInvoiceCashEntry( + null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business', 'Leverantör AB' + ) + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300, Leverantör AB') + }) + + it('description falls back without supplier name', async () => { + const invoice = makeSupplierInvoice({ supplier_invoice_number: 'LF-300' }) const items = [makeItem()] await createSupplierInvoiceCashEntry( @@ -714,7 +770,7 @@ describe('createSupplierInvoiceCashEntry', () => { ) const input = mockedCreateEntry.mock.calls[0][2] - expect(input.description).toContain('kontantmetoden') + expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300') }) }) @@ -862,6 +918,24 @@ describe('createSupplierCreditNoteEntry', () => { expect(input.lines[0].account_number).toBe('2440') }) + it('description includes supplier name when provided', async () => { + const creditNote = makeSupplierInvoice({ + is_credit_note: true, + supplier_invoice_number: 'LF-400', + arrival_number: 7, + total: -10000, + vat_amount: -2000, + }) + const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })] + + await createSupplierCreditNoteEntry( + null as never, 'user-1', creditNote, items, 'swedish_business', 'Leverantör AB' + ) + + const input = mockedCreateEntry.mock.calls[0][2] + expect(input.description).toBe('Kreditfaktura leverantör LF-400, Leverantör AB (ankomst 7)') + }) + it('sets source_type to supplier_credit_note', async () => { const creditNote = makeSupplierInvoice({ id: 'si-cn-1', is_credit_note: true }) const items = [makeItem()] diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 4d58d980..85b079fd 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -184,7 +184,7 @@ export async function createDraftEntry( .insert(lineInserts) if (linesError) { - await supabase.from('journal_entries').delete().eq('id', entry.id) + await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id) throw new Error(`Failed to create journal entry lines: ${linesError.message}`) } @@ -378,7 +378,7 @@ export async function reverseEntry( .insert(lineInserts) if (linesError) { - await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) + await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) throw new Error(`Failed to create reversal lines: ${linesError.message}`) } @@ -389,8 +389,7 @@ export async function reverseEntry( .eq('id', reversalEntry.id) if (postError) { - await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) - await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) + await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) throw new Error(`Failed to post reversal entry: ${postError.message}`) } @@ -406,9 +405,9 @@ export async function reverseEntry( .select('id') if (casError || !updatedOriginal || updatedOriginal.length === 0) { - // Another concurrent reversal already changed the status — roll back our reversal - await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) - await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) + // Another concurrent reversal already changed the status — mark the orphaned + // reversal as cancelled so it's excluded from reports but remains traceable. + await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) throw new Error('Entry was already reversed by a concurrent operation') } diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 666d8ae2..5de2e17f 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -16,6 +16,18 @@ import type { const log = createLogger('invoice-entries') +/** + * Build a BFL-compliant verifikation description with event type and counterparty. + * Falls back to prefix + invoiceNumber if name is not provided (backward compat). + */ +function buildInvoiceDescription( + prefix: string, invoiceNumber: string, counterpartyName?: string +): string { + return counterpartyName + ? `${prefix} ${invoiceNumber}, ${counterpartyName}` + : `${prefix} ${invoiceNumber}` +} + /** * Group invoice items by VAT rate and generate per-rate revenue + VAT lines. * Returns credit lines only (revenue + VAT). The caller adds the debit side. @@ -142,7 +154,8 @@ export async function createInvoiceJournalEntry( supabase: SupabaseClient, userId: string, invoice: Invoice, - entityType: EntityType = 'enskild_firma' + entityType: EntityType = 'enskild_firma', + customerName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date) if (!fiscalPeriodId) { @@ -213,7 +226,7 @@ export async function createInvoiceJournalEntry( const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: invoice.invoice_date, - description: `Faktura ${invoice.invoice_number}`, + description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName), source_type: 'invoice_created', source_id: invoice.id, lines, @@ -233,7 +246,8 @@ export async function createInvoicePaymentJournalEntry( userId: string, invoice: Invoice, paymentDate: string, - exchangeRateDifference?: number + exchangeRateDifference?: number, + customerName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate) if (!fiscalPeriodId) { @@ -241,7 +255,7 @@ export async function createInvoicePaymentJournalEntry( return null } - const desc = `Betalning faktura ${invoice.invoice_number}` + const desc = buildInvoiceDescription('Inbetalning kundfaktura', invoice.invoice_number, customerName) const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate) const lines: CreateJournalEntryLineInput[] = [] @@ -326,7 +340,8 @@ export async function createCreditNoteJournalEntry( supabase: SupabaseClient, userId: string, creditNote: Invoice, - entityType: EntityType = 'enskild_firma' + entityType: EntityType = 'enskild_firma', + customerName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date) if (!fiscalPeriodId) { @@ -391,7 +406,7 @@ export async function createCreditNoteJournalEntry( const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: creditNote.invoice_date, - description: `Kreditfaktura ${creditNote.invoice_number}`, + description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName), source_type: 'credit_note', source_id: creditNote.id, lines, @@ -413,7 +428,8 @@ export async function createInvoiceCashEntry( userId: string, invoice: Invoice, paymentDate: string, - entityType: EntityType = 'enskild_firma' + entityType: EntityType = 'enskild_firma', + customerName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate) if (!fiscalPeriodId) { @@ -462,7 +478,7 @@ export async function createInvoiceCashEntry( account_number: '1930', debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate), credit_amount: 0, - line_description: `Betalning faktura ${invoice.invoice_number}`, + line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName), }) lines.push(...creditLines) @@ -470,7 +486,7 @@ export async function createInvoiceCashEntry( const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: paymentDate, - description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`, + description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName), source_type: 'invoice_cash_payment', source_id: invoice.id, lines, diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts index d655a11f..9f95e053 100644 --- a/lib/bookkeeping/supplier-invoice-entries.ts +++ b/lib/bookkeeping/supplier-invoice-entries.ts @@ -13,6 +13,19 @@ import type { const log = createLogger('supplier-invoice-entries') +/** + * Build a BFL-compliant verifikation description with event type, counterparty, and suffix. + * Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat). + */ +function buildSupplierDescription( + prefix: string, invoiceNumber: string, supplierName?: string, suffix?: string +): string { + const base = supplierName + ? `${prefix} ${invoiceNumber}, ${supplierName}` + : `${prefix} ${invoiceNumber}` + return suffix ? `${base} ${suffix}` : base +} + /** * Create journal entry when a supplier invoice is registered (accrual method) * @@ -35,7 +48,8 @@ export async function createSupplierInvoiceRegistrationEntry( userId: string, invoice: SupplierInvoice, items: SupplierInvoiceItem[], - supplierType: string + supplierType: string, + supplierName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date) if (!fiscalPeriodId) { @@ -44,7 +58,7 @@ export async function createSupplierInvoiceRegistrationEntry( } const lines: CreateJournalEntryLineInput[] = [] - const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})` + const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`) const isForeign = invoice.currency !== 'SEK' // Aggregate expense amounts by account number and convert to SEK @@ -134,7 +148,8 @@ export async function createSupplierInvoicePaymentEntry( invoice: SupplierInvoice, paymentAmount: number, paymentDate: string, - exchangeRateDifference?: number + exchangeRateDifference?: number, + supplierName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate) if (!fiscalPeriodId) { @@ -142,7 +157,7 @@ export async function createSupplierInvoicePaymentEntry( return null } - const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})` + const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`) const lines: CreateJournalEntryLineInput[] = [] if (exchangeRateDifference && exchangeRateDifference !== 0) { @@ -227,7 +242,8 @@ export async function createSupplierInvoiceCashEntry( invoice: SupplierInvoice, items: SupplierInvoiceItem[], paymentDate: string, - supplierType: string + supplierType: string, + supplierName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate) if (!fiscalPeriodId) { @@ -235,7 +251,7 @@ export async function createSupplierInvoiceCashEntry( return null } - const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)` + const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName) const lines: CreateJournalEntryLineInput[] = [] // Aggregate expense amounts by account number and convert to SEK @@ -317,7 +333,8 @@ export async function createSupplierCreditNoteEntry( userId: string, creditNote: SupplierInvoice, items: SupplierInvoiceItem[], - supplierType: string + supplierType: string, + supplierName?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date) if (!fiscalPeriodId) { @@ -325,7 +342,7 @@ export async function createSupplierCreditNoteEntry( return null } - const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})` + const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomst ${creditNote.arrival_number})`) const lines: CreateJournalEntryLineInput[] = [] // Credit: Expense accounts (reverse, in SEK) diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 40076476..4f20347b 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -285,6 +285,7 @@ async function calculateReverseChargeBases( const supplierSourceTypes = [ 'supplier_invoice_registered', 'supplier_invoice_cash_payment', + 'supplier_credit_note', ] const entries = await fetchAllRows<{ id: string diff --git a/supabase/migrations/20260319000001_add_cancelled_journal_status.sql b/supabase/migrations/20260319000001_add_cancelled_journal_status.sql new file mode 100644 index 00000000..17042d2d --- /dev/null +++ b/supabase/migrations/20260319000001_add_cancelled_journal_status.sql @@ -0,0 +1,65 @@ +-- Add 'cancelled' status to journal entries for BFL varaktighet compliance. +-- Once a row is inserted into journal_entries, it must remain traceable. +-- Application code uses status='cancelled' instead of DELETE. + +-- 1. Expand status CHECK to include 'cancelled' +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_status_check; +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_status_check + CHECK (status IN ('draft', 'posted', 'reversed', 'cancelled')); + +-- 2. Update immutability trigger: block all DELETEs, allow draft->cancelled +CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + -- No exemption for drafts: varaktighet applies from insertion. + -- Application code uses status='cancelled' instead of DELETE. + RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.', + OLD.id, OLD.status; + END IF; + + -- Draft can transition to draft (update fields), posted, or cancelled + IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN + RETURN NEW; + END IF; + + -- Posted can transition to reversed (storno) or cancelled (orphaned concurrent reversal cleanup) + IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN + IF NEW.status = 'reversed' THEN + IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date + OR NEW.fiscal_period_id != OLD.fiscal_period_id + OR NEW.voucher_number != OLD.voucher_number THEN + RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id; + END IF; + END IF; + RETURN NEW; + END IF; + + RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.', + OLD.status, OLD.id; +END; $$; + +-- 3. Update line immutability: allow operations on cancelled parent entries +CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability() +RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE v_status text; +BEGIN + SELECT status INTO v_status FROM public.journal_entries + WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id); + + -- Draft entries: all operations allowed + IF v_status = 'draft' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END IF; + + -- Cancelled entries: only DELETE for cleanup + IF v_status = 'cancelled' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP; + END IF; + + RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status; +END; $$; diff --git a/types/index.ts b/types/index.ts index ba0f646a..9a4ff2d3 100644 --- a/types/index.ts +++ b/types/index.ts @@ -763,7 +763,7 @@ export type JournalEntrySourceType = | 'currency_revaluation' // Journal entry status -export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' +export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled' // Mapping rule type export type MappingRuleType =