Files
picpeak/backend/__tests__/integration/invoiceDunning.test.js
T
Paul NothaftandPaul Nothaft 962f1d9586 fix(tests): raise jest timeouts to the 120s convention (stable) (#902)
Stable backport combining #860 (never reached stable) and #900:

- jest.config.js gains testTimeout: 120000 — stable still ran on Jest's
  5s default for anything unpinned, while its migration chain (134 core
  migrations via backports) is nearly as long as beta's.
- All 19 suite-level jest.setTimeout(30000/60000) pins raised to 120s;
  local pins override the config default (#860's rationale).
- All 15 hook-ARGUMENT timeout pins on migration-booting beforeAll
  hooks raised to 120s (#900's rationale — the 3.97.0-beta.0 release PR
  failed on exactly this class on the beta side).

Untouched: the three suites whose pinned hooks don't run migrations
(webhookDelivery, imageProcessor.storage, storageBackend) and
publicQuotes' 30s pin on the rate-limit lockout test.

No test logic changed.

Co-authored-by: Paul Nothaft <[email protected]>
2026-07-29 12:48:50 +02:00

116 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(120000);
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();
});
});