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
218 lines
9.6 KiB
JavaScript
218 lines
9.6 KiB
JavaScript
/**
|
||
* Tests for the migration-119 hierarchy support in quoteService:
|
||
* computeTotals, validateLineItemHierarchy, and the two-phase
|
||
* insertLineItemsHierarchical helper. All pure / db-mocked so the
|
||
* suite runs fast and is deterministic.
|
||
*/
|
||
const quoteService = require('../../src/services/quoteService');
|
||
const {
|
||
computeTotals,
|
||
validateLineItemHierarchy,
|
||
insertLineItemsHierarchical,
|
||
} = quoteService._internal;
|
||
|
||
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
|
||
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
|
||
const items = [
|
||
// Parent with its own price €500 — should be IGNORED because
|
||
// sub-items have prices. Parent's effective line_total becomes
|
||
// sum of priced sub-items.
|
||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||
// Priced sub-items €150 + €200 = €350
|
||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
|
||
// Another top-level item: €100
|
||
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
|
||
];
|
||
const out = computeTotals(items, 0, 0);
|
||
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
|
||
// Parent's own €500 is silently overridden.
|
||
expect(out.netAmountMinor).toBe(45000);
|
||
// Parent's stored line_total_minor reflects the resolved sum.
|
||
expect(out.lineItems[0].line_total_minor).toBe(35000);
|
||
});
|
||
|
||
it('priceless sub-items leave the parent\'s own line_total intact', () => {
|
||
const items = [
|
||
// Parent €500 with three priceless transparency-bullets — the
|
||
// €500 stands.
|
||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||
];
|
||
const out = computeTotals(items, 0, 0);
|
||
expect(out.netAmountMinor).toBe(50000);
|
||
expect(out.lineItems[0].line_total_minor).toBe(50000);
|
||
});
|
||
|
||
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
|
||
const items = [
|
||
// Parent €500 → overridden because at least one sub-item is priced.
|
||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||
// Priceless bullet — doesn't add anything
|
||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||
];
|
||
const out = computeTotals(items, 0, 0);
|
||
// Parent resolves to €150 (only priced sub-item).
|
||
expect(out.netAmountMinor).toBe(15000);
|
||
expect(out.lineItems[0].line_total_minor).toBe(15000);
|
||
});
|
||
|
||
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
|
||
const out = computeTotals([
|
||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
|
||
], 0);
|
||
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
|
||
});
|
||
|
||
it('applies VAT to the resolved parent total', () => {
|
||
const out = computeTotals([
|
||
// Parent €1000 overridden by priced €800 sub-item
|
||
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
|
||
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
|
||
], 7.7);
|
||
// Resolved net = 80000, VAT 7.7% = 6160.
|
||
expect(out.netAmountMinor).toBe(80000);
|
||
expect(out.vatAmountMinor).toBe(6160);
|
||
expect(out.totalAmountMinor).toBe(86160);
|
||
});
|
||
|
||
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
|
||
const out = computeTotals([
|
||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
|
||
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
|
||
], 0);
|
||
expect(out.netAmountMinor).toBe(100000);
|
||
});
|
||
});
|
||
|
||
describe('validateLineItemHierarchy', () => {
|
||
it('accepts a flat list of top-level items', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 1 },
|
||
{ position: 2 },
|
||
{ position: 3 },
|
||
])).not.toThrow();
|
||
});
|
||
|
||
it('accepts one level of sub-items under valid parents', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 1 },
|
||
{ position: 2, parent_position: 1 },
|
||
{ position: 3, parent_position: 1 },
|
||
{ position: 4 },
|
||
{ position: 5, parent_position: 4 },
|
||
])).not.toThrow();
|
||
});
|
||
|
||
it('rejects duplicate positions', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 1 },
|
||
{ position: 1 },
|
||
])).toThrow(/Duplicate line item position/);
|
||
});
|
||
|
||
it('rejects a sub-item pointing at a missing parent', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 1, parent_position: 99 },
|
||
])).toThrow(/missing parent position/);
|
||
});
|
||
|
||
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 1 },
|
||
{ position: 2, parent_position: 1 },
|
||
{ position: 3, parent_position: 2 },
|
||
])).toThrow(/max one level deep/);
|
||
});
|
||
|
||
it('rejects an item whose parent is itself', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 5, parent_position: 5 },
|
||
])).toThrow(/cannot be its own parent/);
|
||
});
|
||
|
||
it('rejects an item without a positive position', () => {
|
||
expect(() => validateLineItemHierarchy([
|
||
{ position: 0 },
|
||
])).toThrow(/positive position/);
|
||
});
|
||
|
||
it('is a no-op on empty / non-array input', () => {
|
||
expect(() => validateLineItemHierarchy([])).not.toThrow();
|
||
expect(() => validateLineItemHierarchy(null)).not.toThrow();
|
||
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('insertLineItemsHierarchical', () => {
|
||
// Tiny trx mock — captures insert calls so we can verify the
|
||
// two-phase ordering and the parent-id remap. `.returning('id')`
|
||
// returns a synthesised id matching the call order.
|
||
function makeTrxMock() {
|
||
let nextId = 100;
|
||
const inserts = []; // [{ table, row }]
|
||
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); }, // bare await: no returning() call
|
||
};
|
||
},
|
||
});
|
||
return { trx, inserts };
|
||
}
|
||
|
||
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
|
||
const { trx, inserts } = makeTrxMock();
|
||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
|
||
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
|
||
]);
|
||
// 4 inserts, all into quote_line_items.
|
||
expect(inserts).toHaveLength(4);
|
||
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
|
||
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
|
||
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
|
||
// Top-level items have parent_line_item_id = null.
|
||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||
expect(inserts[1].row.parent_line_item_id).toBeNull();
|
||
// Sub-items reference the id returned for position-1 parent (100).
|
||
expect(inserts[2].row.parent_line_item_id).toBe(100);
|
||
expect(inserts[3].row.parent_line_item_id).toBe(100);
|
||
// parent_position is stripped (wire-only field, not a DB column).
|
||
expect(inserts[0].row).not.toHaveProperty('parent_position');
|
||
expect(inserts[2].row).not.toHaveProperty('parent_position');
|
||
});
|
||
|
||
it('copies details_text through to the inserted row', async () => {
|
||
const { trx, inserts } = makeTrxMock();
|
||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
|
||
]);
|
||
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
|
||
});
|
||
|
||
it('is a no-op on empty items array', async () => {
|
||
const { trx, inserts } = makeTrxMock();
|
||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
|
||
expect(inserts).toHaveLength(0);
|
||
});
|
||
|
||
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
|
||
const { trx, inserts } = makeTrxMock();
|
||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
|
||
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
|
||
]);
|
||
expect(inserts[0].table).toBe('invoice_line_items');
|
||
expect(inserts[0].row.invoice_id).toBe(42);
|
||
expect(inserts[0].row).not.toHaveProperty('quote_id');
|
||
});
|
||
});
|