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,122 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in invoiceService —
|
||||
* the shared helpers come from quoteService._internal (validated in
|
||||
* quoteService.hierarchy.test.js), so we focus here on the
|
||||
* invoice-specific seams:
|
||||
*
|
||||
* - quote → invoice cloner preserves parent_position + details_text
|
||||
* across the conversion
|
||||
* - the cloner's installment "adjustment" line only reconciles
|
||||
* against TOP-LEVEL cloned items (sub-items don't contribute to
|
||||
* net so they can't appear in the sum)
|
||||
*
|
||||
* Pure helper, no DB.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
|
||||
|
||||
describe('quote → invoice cloner shape', () => {
|
||||
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
|
||||
// take source quote line items (with parent_position) and produce the
|
||||
// `cloned` array that's passed into insertLineItemsHierarchical.
|
||||
function modelCloner(sourceLines) {
|
||||
return sourceLines.map((li) => ({
|
||||
position: parseInt(li.position, 10),
|
||||
quantity: Number(li.quantity || 1),
|
||||
description: li.description,
|
||||
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
|
||||
discount_percent: Number(li.discount_percent || 0),
|
||||
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
|
||||
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
}
|
||||
|
||||
it('preserves parent_position so the hierarchy carries across conversion', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].parent_position).toBeNull();
|
||||
expect(cloned[1].parent_position).toBe(1);
|
||||
expect(cloned[2].parent_position).toBe(1);
|
||||
// The cloned shape passes hierarchy validation — same positions
|
||||
// means the same parent links work without any remap.
|
||||
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves details_text verbatim', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
|
||||
details_text: 'Includes online gallery + 100 high-res downloads.' },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
|
||||
});
|
||||
|
||||
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
|
||||
// Recreate the inner math from scheduleInvoicesForEvent: sum
|
||||
// only line_total_minor where parent_position is null. Sub-items
|
||||
// would otherwise double-count and skew the adjustment.
|
||||
//
|
||||
// Note: the cloner stores raw line_total_minor on each row from
|
||||
// the source quote. By the time this sum runs, the parent's
|
||||
// line_total_minor has already been resolved upstream (via
|
||||
// computeTotals on the quote at save time) — so iterating
|
||||
// top-level only sums the resolved parent totals + standalone
|
||||
// top-level items. Sub-items never contribute here regardless of
|
||||
// whether their parent's total was auto-resolved or not.
|
||||
const cloned = modelCloner([
|
||||
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
|
||||
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
|
||||
// Sub-items €150 + €200 + €100 — shown for transparency, must
|
||||
// NOT enter the reconciliation sum.
|
||||
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
|
||||
// Another top-level €100
|
||||
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
const clonedSum = cloned
|
||||
.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + x.line_total_minor, 0);
|
||||
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
|
||||
expect(clonedSum).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical for invoices', () => {
|
||||
function makeTrxMock() {
|
||||
let nextId = 200;
|
||||
const inserts = [];
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
|
||||
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
]);
|
||||
expect(inserts).toHaveLength(2);
|
||||
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
|
||||
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
|
||||
// Parent inserted first, sub-item second with parent_line_item_id
|
||||
// matching the parent's synthesised id.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user