diff --git a/backend/__tests__/integration/invoiceDunning.test.js b/backend/__tests__/integration/invoiceDunning.test.js new file mode 100644 index 00000000..a1d54301 --- /dev/null +++ b/backend/__tests__/integration/invoiceDunning.test.js @@ -0,0 +1,110 @@ +/** + * Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning + * rework. Covers the fee math (flat / percent), the VAT toggle gating + * (incl. the "no-op when the org has no VAT rate" requirement), per-reminder + * accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never + * changes the issued invoice total), and the 3-reminder cap. + * + * The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and + * is verified manually; here we assert the data/immutability behaviour. + */ +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +let db; +let cleanup; +let invoiceService; +let ids; + +async function setSetting(key, value) { + const { upsertAppSetting } = require('../../src/utils/appSettings'); + await upsertAppSetting(key, JSON.stringify(value), 'crm'); +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ids = await seedMinimal(db); + try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {} + invoiceService = require('../../src/services/invoiceService'); + // Stub the (flaky) PDF render so applyReminder exercises its data path. + // eslint-disable-next-line global-require + const pdfService = require('../../src/services/pdfService'); + pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub'); +}); + +afterAll(async () => { await cleanup(); }); + +describe('dunning fee resolvers', () => { + test('flat fee, no VAT', async () => { + await setSetting('crm_invoices_late_fee_enabled', true); + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', false); + const inv = { total_amount_minor: 100000 }; + expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000); + expect(await invoiceService.resolveLateFeeVatRate()).toBe(0); + expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000); + }); + + test('percent fee = % of the invoice gross', async () => { + await setSetting('crm_invoices_late_fee_type', 'percent'); + await setSetting('crm_invoices_late_fee_percent', 5); + expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000); + }); + + test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => { + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', true); + + await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 }); + expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1); + expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })) + .toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT + + // Org doesn't charge VAT → toggle adds nothing (Mara's requirement). + await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 }); + expect(await invoiceService.resolveLateFeeVatRate()).toBe(0); + expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000); + }); +}); + +describe('applyReminder — dunning-document model', () => { + let invoiceId; + let originalTotal; + + beforeAll(async () => { + await setSetting('crm_invoices_late_fee_enabled', true); + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', false); + const res = await invoiceService.createInvoice({ + customerAccountId: ids.customerId, + currency: 'CHF', + vatRate: 0, + lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }], + }, ids.adminId); + invoiceId = res.invoiceIds[0]; + originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor); + }); + + test('level 2 tracks one fee and leaves the invoice total immutable', async () => { + const data = await invoiceService.getInvoiceById(invoiceId); + await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId); + const inv = await db('invoices').where({ id: invoiceId }).first(); + expect(inv.reminder_level).toBe(2); + expect(Number(inv.late_fee_amount_minor)).toBe(2000); + expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated + }); + + test('level 3 accumulates the fee to 2×, total still immutable', async () => { + const data = await invoiceService.getInvoiceById(invoiceId); + await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId); + const inv = await db('invoices').where({ id: invoiceId }).first(); + expect(Number(inv.late_fee_amount_minor)).toBe(4000); + expect(Number(inv.total_amount_minor)).toBe(originalTotal); + }); + + test('sendReminder refuses to exceed level 3', async () => { + await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow(); + }); +}); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 775a5eb1..72f5beb4 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -3528,6 +3528,10 @@ module.exports = { validateInstallmentPlanInput, sendInvoice, sendReminder, + applyReminder, + resolveLateFeeNetMinor, + resolveLateFeeVatRate, + resolvePerReminderFeeMinor, markPaid, cancelInvoice, releaseForDelivery,