d543949188
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
/**
|
|
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
|
|
*
|
|
* Validation tests run against the pure validator directly. Orchestration
|
|
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
|
|
* — chains are queued per table and assertions probe insert/update/delete
|
|
* call shapes rather than SQL.
|
|
*/
|
|
|
|
const chains = [];
|
|
function makeChain() {
|
|
const c = {
|
|
_firstValue: undefined,
|
|
_updateResult: 1,
|
|
_insertResult: [{ id: 999 }],
|
|
_selectResult: [],
|
|
then: function (onResolve, onReject) {
|
|
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
|
},
|
|
where: jest.fn(function () { return this; }),
|
|
whereNot: jest.fn(function () { return this; }),
|
|
whereIn: jest.fn(function () { return this; }),
|
|
whereNull: jest.fn(function () { return this; }),
|
|
whereNotNull: jest.fn(function () { return this; }),
|
|
andWhere: jest.fn(function () { return this; }),
|
|
orderBy: jest.fn(function () { return this; }),
|
|
limit: jest.fn(function () { return this; }),
|
|
select: jest.fn(function () { return this; }),
|
|
sum: jest.fn(function () { return this; }),
|
|
count: jest.fn(function () { return this; }),
|
|
clone: jest.fn(function () { return this; }),
|
|
clearSelect: jest.fn(function () { return this; }),
|
|
clearOrder: jest.fn(function () { return this; }),
|
|
offset: jest.fn(function () { return this; }),
|
|
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
|
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
|
insert: jest.fn(function () { return this; }),
|
|
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
|
del: jest.fn(function () { return Promise.resolve(1); }),
|
|
onConflict: jest.fn(function () { return this; }),
|
|
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
|
merge: jest.fn(function () { return Promise.resolve(1); }),
|
|
increment: jest.fn(function () { return this; }),
|
|
forUpdate: jest.fn(function () { return this; }),
|
|
leftJoin: jest.fn(function () { return this; }),
|
|
};
|
|
chains.push(c);
|
|
return c;
|
|
}
|
|
|
|
const tableChains = {};
|
|
function pickChainFor(name) {
|
|
if (!tableChains[name]) tableChains[name] = makeChain();
|
|
return tableChains[name];
|
|
}
|
|
|
|
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
|
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
|
|
|
jest.mock('../../src/database/db', () => ({
|
|
db: mockDbFn,
|
|
withRetry: jest.fn(async (fn) => fn()),
|
|
logActivity: jest.fn(async () => {}),
|
|
}));
|
|
|
|
jest.mock('../../src/utils/appSettings', () => ({
|
|
getAppSetting: jest.fn(async () => null),
|
|
}));
|
|
|
|
jest.mock('../../src/services/businessProfileService', () => ({
|
|
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
|
resolveBankAccountForCurrency: jest.fn(async () => null),
|
|
}));
|
|
|
|
jest.mock('../../src/utils/documentSequences', () => ({
|
|
claimNextSequence: jest.fn(async () => 42),
|
|
}));
|
|
|
|
jest.mock('../../src/services/pdfService', () => ({
|
|
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
|
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
|
}));
|
|
|
|
jest.mock('../../src/services/emailProcessor', () => ({
|
|
queueEmail: jest.fn(async () => {}),
|
|
}));
|
|
|
|
jest.mock('../../src/utils/logger', () => ({
|
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
|
}));
|
|
|
|
const invoiceService = require('../../src/services/invoiceService');
|
|
|
|
function resetChains() {
|
|
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
|
}
|
|
|
|
describe('validateInstallmentPlanInput', () => {
|
|
const { validateInstallmentPlanInput } = invoiceService;
|
|
|
|
it('throws on empty array', () => {
|
|
expect(() => validateInstallmentPlanInput([]))
|
|
.toThrow(/non-empty array/);
|
|
});
|
|
|
|
it('throws on non-array', () => {
|
|
expect(() => validateInstallmentPlanInput(null))
|
|
.toThrow(/non-empty array/);
|
|
});
|
|
|
|
it('throws on out-of-range percent', () => {
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
|
|
])).toThrow(/percent must be between 0 and 100/);
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
|
|
])).toThrow(/percent must be between 0 and 100/);
|
|
});
|
|
|
|
it('throws on unknown trigger', () => {
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
|
|
])).toThrow(/invalid trigger/);
|
|
});
|
|
|
|
it('throws when percents do not sum to 100', () => {
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
|
{ percent: 50, trigger: 'before_event', offset_days: -7 },
|
|
])).toThrow(/must sum to 100/);
|
|
});
|
|
|
|
it('accepts a valid three-row plan with mixed triggers', () => {
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
|
|
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
|
|
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
|
|
])).not.toThrow();
|
|
});
|
|
|
|
it('tolerates 0.001 rounding drift in the sum', () => {
|
|
expect(() => validateInstallmentPlanInput([
|
|
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
|
|
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
|
|
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
|
|
])).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('updateInstallmentPlan — guards', () => {
|
|
beforeEach(() => resetChains());
|
|
|
|
const goodPlan = [
|
|
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
|
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
|
|
];
|
|
|
|
it('rejects when dealUuid is missing', async () => {
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('404s when the deal has no invoices', async () => {
|
|
pickChainFor('invoices')._selectResult = [];
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
|
|
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
|
|
];
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
|
|
});
|
|
|
|
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
|
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
|
|
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
|
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
|
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
|
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
|
];
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
|
|
});
|
|
|
|
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
|
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
|
|
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
|
|
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
|
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
|
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
|
];
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
|
|
});
|
|
|
|
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
|
|
const badPlan = [
|
|
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
|
{ percent: 30, trigger: 'before_event', offset_days: -7 },
|
|
];
|
|
await expect(invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
|
|
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
|
|
});
|
|
});
|
|
|
|
describe('updateInstallmentPlan — reshape (smoke)', () => {
|
|
beforeEach(() => resetChains());
|
|
|
|
const sibling = (overrides) => ({
|
|
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
|
|
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
|
|
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
|
shipping_amount_minor: 0, vat_rate: 7.7,
|
|
customer_account_id: 5, source_quote_id: null, event_id: null,
|
|
event_name: 'Wedding', event_date: '2026-08-15',
|
|
language: 'de', currency: 'CHF',
|
|
issue_date: '2026-05-25', due_date: '2026-06-24',
|
|
cc_pdf_email: null,
|
|
payment_net_days_template_id: null, payment_timing_template_id: null,
|
|
payment_term_snapshot: null,
|
|
...overrides,
|
|
});
|
|
|
|
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
|
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
|
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
|
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
|
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
|
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
|
];
|
|
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
|
pickChainFor('invoice_line_items')._selectResult = [];
|
|
|
|
const result = await invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
|
installments: [
|
|
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
|
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
|
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
|
|
],
|
|
});
|
|
|
|
expect(result.kept).toEqual([1, 2, 3]);
|
|
expect(result.created).toEqual([]);
|
|
expect(result.deleted).toEqual([]);
|
|
// Sequence helper never touched on a same-count reshape.
|
|
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
|
expect(claimNextSequence).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
|
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
|
installment_total: 2 }),
|
|
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
|
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
|
installment_total: 2 }),
|
|
];
|
|
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
|
pickChainFor('invoice_line_items')._selectResult = [];
|
|
pickChainFor('invoices')._insertResult = [{ id: 99 }];
|
|
|
|
const result = await invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
|
installments: [
|
|
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
|
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
|
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
|
|
],
|
|
});
|
|
|
|
expect(result.kept).toEqual([1, 2]);
|
|
expect(result.created.length).toBe(1);
|
|
expect(result.deleted).toEqual([]);
|
|
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
|
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shrinks 3→2 by deleting the third row + its line items', async () => {
|
|
pickChainFor('invoices')._selectResult = [
|
|
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
|
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
|
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
|
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
|
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
|
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
|
];
|
|
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
|
pickChainFor('invoice_line_items')._selectResult = [];
|
|
|
|
const result = await invoiceService.updateInstallmentPlan({
|
|
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
|
installments: [
|
|
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
|
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
|
|
],
|
|
});
|
|
|
|
expect(result.kept).toEqual([1, 2]);
|
|
expect(result.created).toEqual([]);
|
|
expect(result.deleted).toEqual([3]);
|
|
// Line items + invoice rows deleted on the trimmed sibling.
|
|
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
|
|
expect(pickChainFor('invoices').del).toHaveBeenCalled();
|
|
});
|
|
});
|