Files
accounted/lib/invoices/__tests__/issue-credit-note.test.ts
T
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00

220 lines
7.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events'
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
import type { Logger } from '@/lib/logger'
import type { CreditNote } from '@/types'
const mockCreateCreditNoteJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createCreditNoteJournalEntry: (...args: unknown[]) =>
mockCreateCreditNoteJournalEntry(...args),
}))
const mockCancelSchedulesForSource = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
cancelSchedulesForSource: (...args: unknown[]) =>
mockCancelSchedulesForSource(...args),
}))
import {
creditNoteNeedsJournalEntry,
issueCreditNote,
} from '@/lib/invoices/issue-credit-note'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const log: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
}
function makeCreditNote(overrides: Partial<CreditNote> = {}) {
return {
...makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-100',
invoice_date: '2026-07-14',
status: 'draft',
credited_invoice_id: 'invoice-1',
subtotal: -1000,
vat_amount: -250,
total: -1250,
...overrides,
}),
customer: { name: 'Testkund' },
} as CreditNote & { customer: { name: string } }
}
describe('issueCreditNote', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockCancelSchedulesForSource.mockResolvedValue({
cancelledSchedules: 1,
reversedEntries: 0,
failedReversals: 0,
})
})
it('books an accrual credit note, links it, cancels accruals, and marks the original', async () => {
enqueue({ data: null, error: null })
enqueue({ data: { voucher_series: 'A', voucher_number: 42 }, error: null })
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-1' })
enqueue({ data: [{ id: 'credit-1' }], error: null })
enqueue({ data: [{ id: 'invoice-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const result = await issueCreditNote({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
creditNote: makeCreditNote(),
originalInvoice: {
id: 'invoice-1',
invoice_number: 'F-100',
status: 'sent',
journal_entry_id: 'original-journal-1',
},
entityType: 'enskild_firma',
accountingMethod: 'accrual',
log,
})
expect(result).toEqual({
complete: true,
journalEntryId: 'journal-1',
journalEntryRequired: true,
repairRequired: false,
failures: [],
})
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'credit-1', status: 'sent' }),
'enskild_firma',
'Testkund',
'A-42',
)
expect(mockCancelSchedulesForSource).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
{ invoiceId: 'invoice-1' },
{ reversalDate: '2026-07-14' },
)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'credit_note.created' }),
)
})
it('marks and emits a cash-method credit note without creating a journal entry', async () => {
enqueue({ data: [{ id: 'invoice-1' }], error: null })
const result = await issueCreditNote({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
creditNote: makeCreditNote(),
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
entityType: 'enskild_firma',
accountingMethod: 'cash',
log,
})
expect(result).toEqual({
complete: true,
journalEntryId: null,
journalEntryRequired: false,
repairRequired: false,
failures: [],
})
expect(mockCreateCreditNoteJournalEntry).not.toHaveBeenCalled()
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
})
it('does not cancel accrual schedules when the credit journal entry fails', async () => {
enqueue({ data: null, error: null })
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('Perioden är låst'))
const result = await issueCreditNote({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
creditNote: makeCreditNote(),
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
entityType: 'enskild_firma',
accountingMethod: 'accrual',
log,
})
expect(result.journalEntryId).toBeNull()
expect(result.complete).toBe(false)
expect(result.failures).toEqual([
{ step: 'journal_entry', reason: 'Perioden är låst' },
])
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
})
it('books a paid cash-method original before marking it credited', async () => {
enqueue({ data: null, error: null })
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-cash' })
enqueue({ data: [{ id: 'credit-1' }], error: null })
enqueue({ data: [{ id: 'invoice-1' }], error: null })
const result = await issueCreditNote({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
creditNote: makeCreditNote(),
originalInvoice: {
id: 'invoice-1',
invoice_number: 'F-100',
status: 'paid',
paid_at: '2026-07-01T00:00:00Z',
},
entityType: 'enskild_firma',
accountingMethod: 'cash',
log,
})
expect(result.complete).toBe(true)
expect(result.journalEntryId).toBe('journal-cash')
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledOnce()
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
})
it('detects when cash-method credit notes need a reversal voucher', () => {
const original = { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' }
expect(creditNoteNeedsJournalEntry('cash', original)).toBe(false)
expect(creditNoteNeedsJournalEntry('cash', { ...original, status: 'paid' })).toBe(true)
expect(creditNoteNeedsJournalEntry('cash', { ...original, paid_amount: 100 })).toBe(true)
expect(creditNoteNeedsJournalEntry('accrual', original)).toBe(true)
})
it('reuses the posted voucher that wins a concurrent create race', async () => {
enqueue({ data: null, error: null })
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('duplicate source'))
enqueue({ data: { id: 'journal-winner' }, error: null })
enqueue({ data: [{ id: 'credit-1' }], error: null })
enqueue({ data: [{ id: 'invoice-1' }], error: null })
const result = await issueCreditNote({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
creditNote: makeCreditNote(),
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
entityType: 'enskild_firma',
accountingMethod: 'accrual',
log,
})
expect(result.complete).toBe(true)
expect(result.journalEntryId).toBe('journal-winner')
expect(result.failures).toEqual([])
})
})