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,251 @@
|
||||
/**
|
||||
* Smoke tests for taxReportService.renderTaxReportPdf and
|
||||
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
|
||||
* businessProfileService (canned issuer) and assert that the
|
||||
* rendered output meets a few hard requirements:
|
||||
*
|
||||
* - PDF starts with the %PDF magic bytes, is non-empty
|
||||
* - CSV header contains the localised column names
|
||||
* - CSV body contains the invoice numbers in order
|
||||
* - CSV totals row contains the grand totals
|
||||
*/
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
return {
|
||||
_rows: initialRows,
|
||||
then(onResolve, onReject) {
|
||||
return Promise.resolve(this._rows).then(onResolve, onReject);
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
callCount += 1;
|
||||
// Route by table name when supplied — the Skonto aggregate (added
|
||||
// by migration 126) queries `invoice_payment_log`; everything else
|
||||
// (main listing, replacements lookup) hits `invoices`.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments so the raw() return value just needs to exist.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: {
|
||||
company_name: 'ACME Test GmbH',
|
||||
address_line1: 'Teststrasse 1',
|
||||
postal_code: '8000',
|
||||
city: 'Zürich',
|
||||
country_code: 'CH',
|
||||
email: '[email protected]',
|
||||
default_locale: 'de',
|
||||
default_currency: 'CHF',
|
||||
pdf_show_logo: 1,
|
||||
pdf_show_company_name: 1,
|
||||
pdf_logo_height: 56,
|
||||
pdf_company_name_inline: 0,
|
||||
pdf_folding_marks: 'none',
|
||||
logo_path: null,
|
||||
pdf_font_ttf_path: null,
|
||||
},
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
|
||||
}));
|
||||
|
||||
const taxReportService = require('../../src/services/taxReportService');
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
|
||||
const SAMPLE_ROW = (override = {}) => ({
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
|
||||
customer_last_name: null, customer_display_name: null, customer_email: null,
|
||||
event_name: 'Hochzeit Müller',
|
||||
...override,
|
||||
});
|
||||
|
||||
describe('renderTaxReportPdf', () => {
|
||||
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders a header even when no invoices are in the period', async () => {
|
||||
invoiceRowsForRun = [];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders successfully when cancelled rows are present', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
|
||||
];
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders without throwing when a row has long text that must wrap', async () => {
|
||||
// Customer + event labels long enough to force multi-line wrap
|
||||
// in their narrow columns. The dynamic row-height logic should
|
||||
// grow the row to fit rather than overlapping the next one.
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
|
||||
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
|
||||
}),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('honours the locale parameter (en) without throwing', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
// Regression: the page-number footer used to place its baseline
|
||||
// inside the bottom margin, which made PDFKit auto-paginate one
|
||||
// empty page per existing page (so a 1-page report ended up as 2,
|
||||
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
|
||||
// is the cheapest way to detect a recurrence without parsing the
|
||||
// PDF — every page object in the xref table carries that marker
|
||||
// exactly once.
|
||||
it('does not duplicate pages when stamping the page-number footer', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
|
||||
// Small single-row report should fit on a single page. The
|
||||
// previous buggy renderer produced 2 (1 content + 1 footer-only).
|
||||
expect(pageMarkers.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTaxReportCsv', () => {
|
||||
it('returns a CSV blob with the de localised header row', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(contentType).toMatch(/text\/csv/);
|
||||
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
|
||||
const lines = content.split('\r\n');
|
||||
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice
|
||||
expect(lines[0]).toContain('Kunde');
|
||||
expect(lines[0]).toContain('Netto');
|
||||
});
|
||||
|
||||
it('lists each invoice on its own row in order', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
const idxA = content.indexOf('R-2026-0001');
|
||||
const idxB = content.indexOf('R-2026-0002');
|
||||
const idxC = content.indexOf('R-2026-0003');
|
||||
expect(idxA).toBeGreaterThan(0);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(idxC).toBeGreaterThan(idxB);
|
||||
});
|
||||
|
||||
it('appends a trailing totals row with the grand totals', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
}),
|
||||
SAMPLE_ROW({
|
||||
id: 2, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
}),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
|
||||
expect(content).toMatch(/"150\.00"/);
|
||||
expect(content).toMatch(/"11\.55"/);
|
||||
expect(content).toMatch(/"161\.55"/);
|
||||
});
|
||||
|
||||
it('marks cancelled rows with a 1 in the cancelled column', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ status: 'cancelled' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Migration 126 added a trailing Skonto column. The cancelled
|
||||
// marker is now second-to-last; the Skonto cell is empty for
|
||||
// non-Skonto rows. Asserting on a regex keeps the test stable
|
||||
// against future trailing-column additions.
|
||||
const dataRow = content.split('\r\n')[1];
|
||||
expect(/"1","[^"]*"$/.test(dataRow)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(content).toContain('\r\n');
|
||||
// The route wraps the BOM around the content; the service output
|
||||
// itself is BOM-free so callers (tests) get a clean string.
|
||||
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user