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
166 lines
5.7 KiB
JavaScript
166 lines
5.7 KiB
JavaScript
/**
|
||
* Unit tests for the pure helpers in customerHoursService (migration
|
||
* 129). The CRUD paths themselves are exercised end-to-end via the
|
||
* admin/customers routes during manual QA; this file covers the
|
||
* deterministic logic so regressions in the rate / duration / lock
|
||
* resolution show up before they hit a real invoice.
|
||
*/
|
||
|
||
const path = require('path');
|
||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
|
||
|
||
// The service imports invoiceService which pulls in the DB. We don't
|
||
// need either for the pure helpers — stub the DB layer so the
|
||
// require chain doesn't try to connect to anything.
|
||
jest.mock('../../src/database/db', () => ({
|
||
db: jest.fn(),
|
||
logActivity: jest.fn(),
|
||
withRetry: (fn) => fn(),
|
||
}));
|
||
jest.mock('../../src/services/invoiceService', () => ({}));
|
||
|
||
const { _internal } = require(servicePath);
|
||
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
|
||
|
||
describe('computeDurationMinutes', () => {
|
||
it('returns minute count for a basic window', () => {
|
||
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
|
||
});
|
||
|
||
it('handles single-minute precision', () => {
|
||
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
|
||
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
|
||
});
|
||
|
||
it('rejects malformed input', () => {
|
||
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
|
||
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
|
||
});
|
||
|
||
it('rejects zero or negative duration', () => {
|
||
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
|
||
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
|
||
});
|
||
});
|
||
|
||
describe('resolveEffectiveRate', () => {
|
||
it('prefers the per-entry override when set', () => {
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: 20000 },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(20000);
|
||
});
|
||
|
||
it('falls back to the customer default when no override', () => {
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: null },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(15000);
|
||
});
|
||
|
||
it('throws when both override and customer rate are unset', () => {
|
||
expect(() => resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: null },
|
||
{ hourly_rate_minor: null },
|
||
)).toThrow(/No hourly rate/);
|
||
});
|
||
|
||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||
// tracking. Must NOT fall through to the customer default.
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: 0 },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('isEntryLocked', () => {
|
||
it('unbilled entry → not locked', () => {
|
||
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
|
||
});
|
||
|
||
it('monthly draft → not locked (still accumulating)', () => {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('standalone draft with no send time → not locked', () => {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('future-scheduled draft → not locked', () => {
|
||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
|
||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
|
||
)).toBe(true);
|
||
});
|
||
|
||
it('sent / paid / overdue / cancelled → locked', () => {
|
||
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
|
||
)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('entry references a deleted invoice (null) → treat as unbilled', () => {
|
||
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('buildLineItemFromEntry', () => {
|
||
const baseEntry = {
|
||
entry_date: '2026-05-20',
|
||
start_time: '09:00',
|
||
end_time: '11:30',
|
||
duration_minutes: 150,
|
||
description: 'Editing wedding photos',
|
||
};
|
||
|
||
it('formats the description per spec', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h): Editing wedding photos');
|
||
});
|
||
|
||
it('omits the colon when no description', () => {
|
||
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
|
||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)');
|
||
});
|
||
|
||
it('quantity is decimal hours with 2 places', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||
expect(li.quantity).toBeCloseTo(2.5, 5);
|
||
});
|
||
|
||
it('line_total rounds correctly for non-clean durations', () => {
|
||
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
|
||
const li = buildLineItemFromEntry(
|
||
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
|
||
10000,
|
||
);
|
||
expect(li.line_total_minor).toBe(2500);
|
||
});
|
||
|
||
it('zero-rate line items produce a zero total without exploding', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 0);
|
||
expect(li.line_total_minor).toBe(0);
|
||
expect(li.unit_price_minor).toBe(0);
|
||
});
|
||
});
|