The workflow/dunning suites boot the full core-migration set in beforeAll via bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a small CI runner it can exceed Jest's 5s default, timing out beforeAll and failing every test in the file (the CI flake). Match the existing pattern used by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill) and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and invoiceDunning.
116 lines
5.2 KiB
JavaScript
116 lines
5.2 KiB
JavaScript
/**
|
||
* 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');
|
||
|
||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||
jest.setTimeout(30000);
|
||
|
||
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();
|
||
});
|
||
});
|