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,124 @@
|
||||
/**
|
||||
* Tests for the ISO 13616 IBAN validator.
|
||||
*
|
||||
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
|
||||
* — they are publicly published sample values used by every IBAN
|
||||
* implementation as test vectors. NOT real account numbers.
|
||||
*/
|
||||
const { validateIban, _internal } = require('../../src/utils/iban');
|
||||
|
||||
describe('validateIban', () => {
|
||||
it('accepts a canonical Swiss IBAN', () => {
|
||||
const out = validateIban('CH9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
expect(out.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a Liechtenstein IBAN', () => {
|
||||
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a German IBAN', () => {
|
||||
expect(validateIban('DE89370400440532013000').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an Austrian IBAN', () => {
|
||||
expect(validateIban('AT611904300234573201').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a British IBAN with alphanumeric BBAN', () => {
|
||||
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('normalises spaces and lowercase input', () => {
|
||||
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('normalises mixed-case input', () => {
|
||||
const out = validateIban('ch9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('rejects an empty / null / undefined value', () => {
|
||||
expect(validateIban('').reason).toBe('EMPTY');
|
||||
expect(validateIban(' ').reason).toBe('EMPTY');
|
||||
expect(validateIban(null).reason).toBe('EMPTY');
|
||||
expect(validateIban(undefined).reason).toBe('EMPTY');
|
||||
});
|
||||
|
||||
it('rejects a malformed string (numbers in the country slot)', () => {
|
||||
const out = validateIban('12930076201162385295');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-short string', () => {
|
||||
expect(validateIban('CH93').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-long string (over 34 chars)', () => {
|
||||
// 35 chars: pads beyond the ISO 13616 max
|
||||
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a known-country IBAN with the wrong length', () => {
|
||||
// CH must be 21 chars; this one is 22.
|
||||
const out = validateIban('CH9300762011623852957X');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('LENGTH');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with a broken checksum', () => {
|
||||
// Same shape, last digit altered.
|
||||
const out = validateIban('CH9300762011623852950');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with internally invalid characters', () => {
|
||||
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('accepts an unknown-country IBAN that meets the generic length range', () => {
|
||||
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
|
||||
// structural regex passes if length is in [15, 34] and the
|
||||
// checksum holds. Build a checksum-valid string:
|
||||
//
|
||||
// Format: ZZ + check + BBAN. We don't have a real ZZ template
|
||||
// so this test just confirms unknown country codes route
|
||||
// through the fallback length check rather than failing on
|
||||
// LENGTH outright. A checksum-failing ZZ value will hit
|
||||
// CHECKSUM, not LENGTH, which is the assertion below.
|
||||
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
|
||||
});
|
||||
});
|
||||
|
||||
describe('mod97', () => {
|
||||
it('returns 1 for the canonical CH test vector', () => {
|
||||
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns something other than 1 for a tampered IBAN', () => {
|
||||
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IBAN_LENGTHS table', () => {
|
||||
it('has the expected lengths for the most common European countries', () => {
|
||||
// Sanity check that the table didn't drift if someone edits it.
|
||||
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
|
||||
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF filename builder used on every
|
||||
* quote / invoice download endpoint + the PDF's internal Title
|
||||
* metadata. No mocks needed — all behavior is deterministic.
|
||||
*/
|
||||
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
|
||||
|
||||
describe('sanitiseSegment', () => {
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitiseSegment(null)).toBe('');
|
||||
expect(sanitiseSegment(undefined)).toBe('');
|
||||
expect(sanitiseSegment('')).toBe('');
|
||||
});
|
||||
|
||||
it('replaces filesystem-hostile characters with "-"', () => {
|
||||
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||
});
|
||||
|
||||
it('collapses spaces into single "-"', () => {
|
||||
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
|
||||
});
|
||||
|
||||
it('collapses repeat dashes', () => {
|
||||
expect(sanitiseSegment('a-----b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('trims leading + trailing dashes/dots', () => {
|
||||
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('preserves non-ASCII letters', () => {
|
||||
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
|
||||
});
|
||||
|
||||
it('caps length at 80 chars by default', () => {
|
||||
const long = 'a'.repeat(120);
|
||||
expect(sanitiseSegment(long)).toHaveLength(80);
|
||||
});
|
||||
|
||||
it('honors custom maxLen', () => {
|
||||
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customerLabel', () => {
|
||||
it('prefers company_name over person name', () => {
|
||||
expect(customerLabel({
|
||||
company_name: 'ACME GmbH',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('ACME-GmbH');
|
||||
});
|
||||
|
||||
it('falls back to first + last when company_name is empty', () => {
|
||||
expect(customerLabel({
|
||||
company_name: '',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
|
||||
it('falls back to display_name when no company + no person', () => {
|
||||
expect(customerLabel({
|
||||
display_name: 'Luca B.',
|
||||
})).toBe('Luca-B');
|
||||
});
|
||||
|
||||
it('falls back to email local-part as a last resort', () => {
|
||||
expect(customerLabel({
|
||||
email: '[email protected]',
|
||||
})).toBe('luca');
|
||||
});
|
||||
|
||||
it('uses "customer" when everything is missing', () => {
|
||||
expect(customerLabel({})).toBe('customer');
|
||||
expect(customerLabel(null)).toBe('customer');
|
||||
});
|
||||
|
||||
it('trims whitespace before evaluating truthiness', () => {
|
||||
// company_name = " " should NOT trigger the company branch.
|
||||
expect(customerLabel({
|
||||
company_name: ' ',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdfFilename', () => {
|
||||
const customer = { company_name: 'ACME GmbH' };
|
||||
|
||||
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer,
|
||||
})).toBe('R-2026-0001_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('falls back to the fallback when docNumber is null (preview)', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: null,
|
||||
customer,
|
||||
fallback: 'invoice-preview',
|
||||
})).toBe('invoice-preview_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('uses "document" when both docNumber + fallback are absent', () => {
|
||||
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('sanitises the customer half too', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: { company_name: 'Bad/Name:Inc.' },
|
||||
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
|
||||
});
|
||||
|
||||
it('always ends with .pdf', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: {},
|
||||
})).toMatch(/\.pdf$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* resolveLogoFile — verifies the path-priority chain + the
|
||||
* unsupported-format guard. fs + appSettings + storage config are
|
||||
* mocked so the test is fully deterministic.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/config/storage', () => ({
|
||||
getStoragePath: jest.fn(() => '/app/storage'),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
|
||||
const { getAppSetting } = require('../../src/utils/appSettings');
|
||||
|
||||
describe('resolveLogoFile', () => {
|
||||
let existsSpy, statSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSpy = jest.spyOn(fs, 'existsSync');
|
||||
statSpy = jest.spyOn(fs, 'statSync');
|
||||
existsSpy.mockReturnValue(false);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
getAppSetting.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns null when no sources are configured', async () => {
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers business_profile.logo_path over branding fallbacks', async () => {
|
||||
// The profile path exists, branding doesn't.
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
|
||||
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
|
||||
const out = await resolveLogoFile({
|
||||
logo_path: 'uploads/logos/profile.png',
|
||||
});
|
||||
expect(out).toBe('/app/storage/uploads/logos/profile.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_path when profile is empty', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({ logo_path: '' });
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('skips SVG (PDFKit cannot embed)', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('also rejects WebP / GIF / TIFF', async () => {
|
||||
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
|
||||
existsSpy.mockReturnValue(true);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts PNG and JPEG', async () => {
|
||||
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats absolute paths as-is when they exist', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
|
||||
expect(out).toBe('/abs/path/logo.png');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user