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
123 lines
5.9 KiB
JavaScript
123 lines
5.9 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|