feat(crm): backend code — services + routes + utilities + tests
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
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user