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
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function item(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
description: 'Arbete',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-07-14T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildCreditNoteItem', () => {
|
||||
it('negates amounts and preserves ROT/RUT, account, accrual, and dimension metadata', () => {
|
||||
const result = buildCreditNoteItem('credit-1', item({
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
}))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
invoice_id: 'credit-1',
|
||||
quantity: -2,
|
||||
line_total: -2000,
|
||||
vat_amount: -500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: -600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import { buildInvoiceCopyInitial, canCopyInvoice } from '@/lib/invoices/copy-invoice'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
line_type: 'product',
|
||||
description: 'Consulting',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('canCopyInvoice', () => {
|
||||
it.each(['sent', 'paid', 'partially_paid', 'overdue', 'credited'] as const)(
|
||||
'allows an issued ordinary invoice with status %s',
|
||||
(status) => {
|
||||
expect(canCopyInvoice(makeInvoice({ status }))).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects drafts, credit notes, other document types, and self-billing invoices', () => {
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'draft' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', credited_invoice_id: 'invoice-original' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', document_type: 'proforma' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', is_self_billed: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInvoiceCopyInitial', () => {
|
||||
it('copies reusable content and clears lifecycle-specific fields', () => {
|
||||
const source = makeInvoice({
|
||||
id: 'invoice-original',
|
||||
invoice_number: 'F-2026007',
|
||||
status: 'paid',
|
||||
invoice_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
delivery_date: '2025-12-20',
|
||||
your_reference: 'Old customer contact',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
payment_link_url: 'https://example.com/old-payment',
|
||||
journal_entry_id: 'journal-1',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
const first = makeItem({
|
||||
sort_order: 1,
|
||||
article_id: 'article-1',
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Old property',
|
||||
apartment_number: '1201',
|
||||
brf_org_number: '5560000000',
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-06-30',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
const second = makeItem({ id: 'item-2', sort_order: 0, description: 'First row' })
|
||||
|
||||
const copy = buildInvoiceCopyInitial({ ...source, items: [first, second] })
|
||||
|
||||
expect(copy).toMatchObject({
|
||||
source_invoice_number: 'F-2026007',
|
||||
customer_id: source.customer_id,
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
expect(copy.items.map((item) => item.description)).toEqual(['First row', 'Consulting'])
|
||||
expect(copy.items[1]).toMatchObject({
|
||||
article_id: null,
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(copy).not.toHaveProperty('invoice_date')
|
||||
expect(copy).not.toHaveProperty('due_date')
|
||||
expect(copy).not.toHaveProperty('your_reference')
|
||||
expect(copy).not.toHaveProperty('payment_link_url')
|
||||
expect(copy).not.toHaveProperty('journal_entry_id')
|
||||
expect(copy).not.toHaveProperty('status')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCustomerInvoicePair(): Promise<{
|
||||
originalInvoiceId: string
|
||||
creditNoteId: string
|
||||
}> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
|
||||
const customerId = randomUUID()
|
||||
const originalInvoiceId = randomUUID()
|
||||
const creditNoteId = randomUUID()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
|
||||
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
|
||||
[customerId, userId, companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
1000, 0, 1000, 'standard_25', 25, 'sent', 0, 1000)`,
|
||||
[originalInvoiceId, userId, companyId, customerId, `F-${originalInvoiceId.slice(0, 8)}`],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount, credited_invoice_id)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
-1000, 0, -1000, 'standard_25', 25, 'sent', 0, -1000, $6)`,
|
||||
[creditNoteId, userId, companyId, customerId, `KR-${creditNoteId.slice(0, 8)}`, originalInvoiceId],
|
||||
)
|
||||
|
||||
return { originalInvoiceId, creditNoteId }
|
||||
}
|
||||
|
||||
describe('invoices_credit_note_not_paid constraint', () => {
|
||||
it.each(['paid', 'partially_paid'])('rejects credit-note status %s', async (status) => {
|
||||
const { creditNoteId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = $1 WHERE id = $2`, [status, creditNoteId]),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
|
||||
it('allows an ordinary customer invoice to be marked paid', async () => {
|
||||
const { originalInvoiceId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = 'paid' WHERE id = $1`, [originalInvoiceId]),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
|
||||
describe('getCreditNoteSendMode', () => {
|
||||
it('prefers email when delivery is available and the customer has an address', () => {
|
||||
expect(getCreditNoteSendMode({
|
||||
customerHasEmail: true,
|
||||
isSandbox: false,
|
||||
canEmail: true,
|
||||
})).toBe('email')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ customerHasEmail: false, isSandbox: false, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: true, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: false, canEmail: false },
|
||||
])('falls back to manual issuance for $customerHasEmail/$isSandbox/$canEmail', (input) => {
|
||||
expect(getCreditNoteSendMode(input)).toBe('manual')
|
||||
})
|
||||
})
|
||||
@@ -184,6 +184,25 @@ describe('findMatchingInvoices', () => {
|
||||
expect(result[0].matchReason).toContain('OCR-referens')
|
||||
})
|
||||
|
||||
it('never suggests a credit note, even when its OCR reference matches', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'KR-F-2024001' })
|
||||
mockResult({
|
||||
data: [
|
||||
makeInvoice({
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns immediately on OCR match without further scoring', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
|
||||
mockResult({
|
||||
|
||||
@@ -29,4 +29,14 @@ describe('isEditableInvoiceDraft', () => {
|
||||
isEditableInvoiceDraft({ status: 'draft', journal_entry_id: null, is_self_billed: true }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks a credit-note draft because it must continue mirroring the original', () => {
|
||||
expect(
|
||||
isEditableInvoiceDraft({
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
credited_invoice_id: 'invoice-1',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCompanySettings(): Promise<string> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id)
|
||||
VALUES ($1, $2)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
return companyId
|
||||
}
|
||||
|
||||
describe('company_settings reminder day constraints', () => {
|
||||
it('uses the legacy 15, 30, and 45 day defaults', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
const result = await getPool().query(
|
||||
`SELECT reminder_days_level_1, reminder_days_level_2, reminder_days_level_3
|
||||
FROM public.company_settings
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
reminder_days_level_1: 15,
|
||||
reminder_days_level_2: 30,
|
||||
reminder_days_level_3: 45,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a strictly increasing custom schedule', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = 7,
|
||||
reminder_days_level_2 = 21,
|
||||
reminder_days_level_3 = 35
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[30, 20, 45],
|
||||
[15, 15, 45],
|
||||
[0, 30, 45],
|
||||
[15, 30, 366],
|
||||
])('rejects invalid schedule %s, %s, %s', async (level1, level2, level3) => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = $1,
|
||||
reminder_days_level_2 = $2,
|
||||
reminder_days_level_3 = $3
|
||||
WHERE company_id = $4`,
|
||||
[level1, level2, level3, companyId],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
determineReminderLevel,
|
||||
calculateDaysOverdue,
|
||||
} from '../reminder-processor'
|
||||
import { getReminderDaysConfig } from '@/lib/email/reminder-templates'
|
||||
|
||||
describe('determineReminderLevel', () => {
|
||||
it('returns null below the level-1 threshold', () => {
|
||||
@@ -60,6 +61,37 @@ describe('determineReminderLevel', () => {
|
||||
it('returns null when all levels have been sent', () => {
|
||||
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a company-specific schedule', () => {
|
||||
const config = { 1: 7, 2: 21, 3: 35 } as const
|
||||
|
||||
expect(determineReminderLevel(6, [], config)).toBeNull()
|
||||
expect(determineReminderLevel(7, [], config)).toBe(1)
|
||||
expect(determineReminderLevel(21, [1], config)).toBe(2)
|
||||
expect(determineReminderLevel(35, [1, 2], config)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReminderDaysConfig', () => {
|
||||
it('returns the existing defaults when no company settings are supplied', () => {
|
||||
expect(getReminderDaysConfig()).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
|
||||
it('returns configured company thresholds', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 5,
|
||||
reminder_days_level_2: 10,
|
||||
reminder_days_level_3: 20,
|
||||
})).toEqual({ 1: 5, 2: 10, 3: 20 })
|
||||
})
|
||||
|
||||
it('falls back to defaults for an invalid stored schedule', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 30,
|
||||
reminder_days_level_2: 20,
|
||||
reminder_days_level_3: 45,
|
||||
})).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDaysOverdue', () => {
|
||||
|
||||
@@ -49,6 +49,28 @@ describe('settleInvoicePayment', () => {
|
||||
vi.mocked(createInvoiceCashEntry).mockResolvedValue({ id: 'je-2' } as never)
|
||||
})
|
||||
|
||||
it('rejects credit notes before creating a journal entry or updating state', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice: payableInvoice({ credited_invoice_id: 'original-invoice-1' }),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NOT_PAYABLE',
|
||||
details: { reason: 'credit_note' },
|
||||
})
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books via the payment entry and forwards the settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
Reference in New Issue
Block a user