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:
Luca
2026-05-26 18:18:51 +02:00
parent 60abe8c76d
commit d543949188
91 changed files with 23578 additions and 123 deletions
@@ -0,0 +1,109 @@
/**
* Unit tests for the pure helpers in contractService (migration 130).
*
* The DB-bound CRUD paths (createContract / sendContract /
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
* manual QA via the admin + public routes. This file covers the
* deterministic helpers so regressions in placeholder substitution or
* section ordering surface before they leak into a rendered contract.
*
* The service pulls in DB-bound peers (businessProfileService,
* pdfService, emailProcessor) at the top level. We stub the DB layer
* + the side-effect peers so the require chain doesn't try to connect
* to anything; the helpers under test are pure.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(),
}));
jest.mock('../../src/services/pdfService', () => ({
renderContractToBuffer: jest.fn(),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(),
}));
const { _internal } = require(servicePath);
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
describe('renderTemplatedBody', () => {
it('substitutes simple {{var}} placeholders', () => {
expect(renderTemplatedBody(
'Hello {{name}}, due in {{net_days}} days.',
{ name: 'Alice', net_days: 30 },
)).toBe('Hello Alice, due in 30 days.');
});
it('preserves unknown placeholders literally so admins notice missing fields', () => {
expect(renderTemplatedBody(
'Bill from {{issuer}} to {{customer_name}}',
{ issuer: 'PicPeak GmbH' },
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
});
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
expect(renderTemplatedBody(
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
{ has_skonto: true, pct: 2 },
)).toBe('Skonto: 2 % on early payment');
});
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
expect(renderTemplatedBody(
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
{ net_days: 30, has_skonto: false, pct: 2 },
)).toBe('Net 30 d.');
});
it('treats missing variables in {{#if}} as falsy', () => {
expect(renderTemplatedBody(
'A{{#if missing}}B{{/if}}C',
{ unrelated: 'foo' },
)).toBe('AC');
});
it('handles empty strings and missing variables map gracefully', () => {
expect(renderTemplatedBody('', { x: 1 })).toBe('');
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
});
it('passes through non-string input unchanged', () => {
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
});
it('substitutes numeric and falsy variable values as strings', () => {
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
});
});
describe('SECTIONS_ORDER', () => {
it('matches the canonical six-section order locked in the spec', () => {
expect(SECTIONS_ORDER).toEqual([
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
]);
});
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
const blocksService = require('../../src/services/contractBlocksService');
expect([...SECTIONS_ORDER].sort()).toEqual(
[...blocksService.ALLOWED_SECTIONS].sort(),
);
});
});
@@ -0,0 +1,213 @@
/**
* Tests for the passive-customer surface:
*
* - createDirect inserts a customer with password_hash=null,
* queueEmail is never called, race-guard rejects duplicates
* - createInvitation allows passing through when the existing
* customer is passive (promotion path); still rejects when the
* existing customer is active (real duplicate)
* - acceptInvitation upserts into an existing passive customer
* row (preserving id) when one exists; inserts a fresh row
* otherwise; still rejects when the existing customer is active
*
* Pure unit tests — db is mocked via a thenable chain so we can
* inspect every insert / update payload without spinning up SQLite.
*/
// ----- mock db chain --------------------------------------------------
//
// We need fine-grained control over which row each table-name returns
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
// what `.update(...)` resolves to. The chain is a thenable proxy that
// terminates on the call we care about.
const tableSeeds = {}; // table → first-row return value
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
const updateCalls = []; // [{ table, where, updates }]
let nextInsertId = 1000;
function resetMockDb() {
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
for (const k of Object.keys(insertResults)) delete insertResults[k];
updateCalls.length = 0;
nextInsertId = 1000;
}
function makeChain(tableName) {
const chain = {
_whereClauses: [],
where(...args) { this._whereClauses.push(args); return this; },
whereNull() { return this; },
whereNot() { return this; },
andWhere() { return this; },
orderBy() { return this; },
leftJoin() { return this; },
groupBy() { return this; },
select(...args) {
// listCustomers / search → return seeded array
const seeded = tableSeeds[`${tableName}__select`];
return Promise.resolve(seeded || []);
},
first() {
const seeded = tableSeeds[tableName];
return Promise.resolve(seeded);
},
insert(payload) {
const id = nextInsertId++;
insertResults[tableName] = insertResults[tableName] || [];
insertResults[tableName].push({ ...payload, id });
const result = { id };
return {
returning() { return Promise.resolve([result]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
update(updates) {
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
return Promise.resolve(1);
},
del() { return Promise.resolve(1); },
raw() { return this; },
};
return chain;
}
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
mockDbFn.raw = jest.fn();
mockDbFn.transaction = async (cb) => cb(mockDbFn);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
const mockQueueEmail = jest.fn(async () => {});
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: mockQueueEmail,
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: { default_locale: 'de' },
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const customerAccountsService = require('../../src/services/customerAccountsService');
beforeEach(() => {
resetMockDb();
mockQueueEmail.mockClear();
});
// --------------------------------------------------------------------
// createDirect
// --------------------------------------------------------------------
describe('createDirect', () => {
it('inserts a customer with password_hash=null, is_active=true', async () => {
tableSeeds.customer_accounts = undefined; // no duplicate
const result = await customerAccountsService.createDirect({
email: '[email protected]',
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
createdByAdminId: 5,
});
expect(result.id).toBeDefined();
const inserted = insertResults.customer_accounts[0];
expect(inserted.email).toBe('[email protected]');
expect(inserted.password_hash).toBeNull();
expect(inserted.created_by_admin_id).toBe(5);
expect(inserted.first_name).toBe('Anna');
expect(inserted.company_name).toBe('ACME GmbH');
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
expect([true, 1, '1']).toContain(inserted.is_active);
});
it('defaults preferred_language from the business profile', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
});
it('honours preferred_language when the admin pre-fills it', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: { preferred_language: 'fr' },
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
});
it('rejects when a customer with the email already exists', async () => {
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: 'whatever' };
await expect(customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/already exists/);
});
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
tableSeeds.customer_accounts = undefined;
await expect(customerAccountsService.createDirect({
email: '',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/Email is required/);
});
it('NEVER queues an invitation email (regression guard)', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
});
expect(mockQueueEmail).not.toHaveBeenCalled();
});
});
// --------------------------------------------------------------------
// createInvitation passive-allowance behaviour
// --------------------------------------------------------------------
describe('createInvitation — duplicate-email guard', () => {
it('still rejects when the existing customer has a password (real duplicate)', async () => {
tableSeeds.customer_accounts = { id: 1, email: '[email protected]', password_hash: 'hash' };
await expect(customerAccountsService.createInvitation({
email: '[email protected]',
invitedById: 5,
prefill: null,
})).rejects.toThrow(/already exists/);
expect(mockQueueEmail).not.toHaveBeenCalled();
});
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: null };
// no pending invitation
// The chain returns `tableSeeds.customer_invitations` for .first()
// and we haven't seeded one, so it's undefined → allowed through.
const out = await customerAccountsService.createInvitation({
email: '[email protected]',
invitedById: 9,
prefill: { first_name: 'Anna' },
});
expect(out.id).toBeDefined();
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,165 @@
/**
* 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:0011: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:0011: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);
});
});
@@ -0,0 +1,119 @@
/**
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
* gates the migration-137 calendar time columns on events.
*
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
* helper and write through hasColumnCached guards; those are
* exercised in manual QA. This file pins the contract so a future
* tweak to the validation rules doesn't silently break it.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
// Stub every DB-bound peer so the require chain doesn't try to open
// a knex connection. The helper under test is pure.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
const { normaliseEventTimeTriple } = require(servicePath);
describe('normaliseEventTimeTriple', () => {
it('defaults to full-day when is_full_day is undefined', () => {
expect(normaliseEventTimeTriple({})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('forces times to null when is_full_day is true even if times are supplied', () => {
expect(normaliseEventTimeTriple({
is_full_day: true,
event_time_start: '10:00',
event_time_end: '12:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('accepts a valid timed range when is_full_day is false', () => {
expect(normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '09:30',
event_time_end: '17:00',
})).toEqual({
event_time_start: '09:30',
event_time_end: '17:00',
is_full_day: false,
});
});
it('throws when is_full_day is false and start is missing/malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '25:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '9:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
});
it('throws when end is missing or malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '12:99',
})).toThrow(/HH:MM/);
});
it('throws when end is at or before start', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '10:00',
})).toThrow(/after/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '15:00',
event_time_end: '10:00',
})).toThrow(/after/);
});
it('parses string boolean flag', () => {
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
// the helper consumes them transparently.
expect(normaliseEventTimeTriple({
is_full_day: 'false',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: '08:00',
event_time_end: '09:00',
is_full_day: false,
});
expect(normaliseEventTimeTriple({
is_full_day: '1',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
});
@@ -0,0 +1,122 @@
/**
* 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);
});
});
@@ -0,0 +1,322 @@
/**
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
*
* Validation tests run against the pure validator directly. Orchestration
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
* — chains are queued per table and assertions probe insert/update/delete
* call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => ({
claimNextSequence: jest.fn(async () => 42),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('validateInstallmentPlanInput', () => {
const { validateInstallmentPlanInput } = invoiceService;
it('throws on empty array', () => {
expect(() => validateInstallmentPlanInput([]))
.toThrow(/non-empty array/);
});
it('throws on non-array', () => {
expect(() => validateInstallmentPlanInput(null))
.toThrow(/non-empty array/);
});
it('throws on out-of-range percent', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
expect(() => validateInstallmentPlanInput([
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
});
it('throws on unknown trigger', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
])).toThrow(/invalid trigger/);
});
it('throws when percents do not sum to 100', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 50, trigger: 'before_event', offset_days: -7 },
])).toThrow(/must sum to 100/);
});
it('accepts a valid three-row plan with mixed triggers', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
])).not.toThrow();
});
it('tolerates 0.001 rounding drift in the sum', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
])).not.toThrow();
});
});
describe('updateInstallmentPlan — guards', () => {
beforeEach(() => resetChains());
const goodPlan = [
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
];
it('rejects when dealUuid is missing', async () => {
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the deal has no invoices', async () => {
pickChainFor('invoices')._selectResult = [];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 404 });
});
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
});
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
});
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
});
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
const badPlan = [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 30, trigger: 'before_event', offset_days: -7 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
});
});
describe('updateInstallmentPlan — reshape (smoke)', () => {
beforeEach(() => resetChains());
const sibling = (overrides) => ({
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
shipping_amount_minor: 0, vat_rate: 7.7,
customer_account_id: 5, source_quote_id: null, event_id: null,
event_name: 'Wedding', event_date: '2026-08-15',
language: 'de', currency: 'CHF',
issue_date: '2026-05-25', due_date: '2026-06-24',
cc_pdf_email: null,
payment_net_days_template_id: null, payment_timing_template_id: null,
payment_term_snapshot: null,
...overrides,
});
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2, 3]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([]);
// Sequence helper never touched on a same-count reshape.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('invoices')._insertResult = [{ id: 99 }];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created.length).toBe(1);
expect(result.deleted).toEqual([]);
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
});
it('shrinks 3→2 by deleting the third row + its line items', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([3]);
// Line items + invoice rows deleted on the trimmed sibling.
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
expect(pickChainFor('invoices').del).toHaveBeenCalled();
});
});
@@ -0,0 +1,374 @@
/**
* Tests for invoiceService lock + state-transition guards.
*
* Focuses on the rules that protect tax/audit integrity:
* - reissueInvoice refuses to act on `scheduled` (use Edit)
* - reissueInvoice cancels + clones any other status
* - releaseForDelivery refuses to act on non-pending_delivery
* - recordPaymentCheckAction refuses already-used / expired tokens
*
* db is deep-mocked so the tests are deterministic and fast.
*/
// Mock db chain: each table call returns a builder whose methods
// chain (return `this`) until a terminal method (.first / .update /
// .insert / .returning) resolves with the queued value.
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
_allRows: [],
// knex chains are thenable — awaiting them runs the query and
// resolves with the row set. We mirror that so callers can
// `await trx('t').where(...).orderBy(...)` and get an array.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
// select is both chainable (`.select('col').first()`) and awaitable
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
// db.transaction(cb) runs the callback with a "trx" — for our
// purposes the same chain factory works as trx.
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('invoiceService.reissueInvoice', () => {
beforeEach(() => resetChains());
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
await expect(invoiceService.reissueInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('throws when the source invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.reissueInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('cancels the original and creates a new row when status is sent', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'sent', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
shipping_amount_minor: 0, cc_pdf_email: null,
business_bank_account_id: null, qr_format: null,
payment_term_template_id: null, event_id: null,
source_quote_id: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, is_active: 1, feature_bills: 1,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const result = await invoiceService.reissueInvoice(1, 42);
expect(result.id).toBeDefined();
expect(result.replaces).toBe(1);
});
});
describe('invoiceService.createStorno', () => {
beforeEach(() => resetChains());
it('rejects when the source invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.createStorno(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('rejects when the source is already cancelled (no double-Storno)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects when asked to Storno a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('inserts a Storno row and flips the original on a sent invoice', async () => {
// Original is `sent`, no line items, no event.
const invoicesChain = pickChainFor('invoices');
invoicesChain._firstValue = {
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
net_amount_minor: 30000, vat_amount_minor: 2310,
total_amount_minor: 32310, shipping_amount_minor: 0,
cc_pdf_email: null, event_id: null,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const stornoId = await invoiceService.createStorno(1, 42);
expect(stornoId).toBeDefined();
// The mock chain's .update() is called twice on `invoices`:
// 1) `.insert(...).returning('id')` for the Storno row
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
// We just verify the helpers were exercised on the right table.
expect(invoicesChain.insert).toHaveBeenCalled();
expect(invoicesChain.update).toHaveBeenCalled();
// The Storno insert payload should carry kind='storno' and
// negated row-level totals. Inspect the first insert call's
// payload to confirm.
const insertedRow = invoicesChain.insert.mock.calls[0][0];
expect(insertedRow.kind).toBe('storno');
expect(insertedRow.net_amount_minor).toBe(-30000);
expect(insertedRow.vat_amount_minor).toBe(-2310);
expect(insertedRow.total_amount_minor).toBe(-32310);
expect(insertedRow.cancels_invoice_id).toBe(1);
expect(insertedRow.status).toBe('scheduled');
// No payment instrument on a Storno.
expect(insertedRow.business_bank_account_id).toBeNull();
expect(insertedRow.qr_format).toBeNull();
expect(insertedRow.payment_term_template_id).toBeNull();
// Storni have no real payment due, but the schema's NOT NULL
// constraint on due_date forces a value — we mirror issue_date.
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
});
});
describe('invoiceService.cancelInvoice', () => {
beforeEach(() => resetChains());
it('rejects when the invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.cancelInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
const result = await invoiceService.cancelInvoice(1, 42);
expect(result).toEqual({ cancelled: true, stornoId: null });
});
});
describe('invoiceService.releaseForDelivery', () => {
beforeEach(() => resetChains());
it('refuses when status is not pending_delivery', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
await expect(invoiceService.releaseForDelivery(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
it('404s when the invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.releaseForDelivery(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe('invoiceService.recordPaymentCheckAction', () => {
beforeEach(() => resetChains());
it('rejects invalid actions', async () => {
await expect(invoiceService.recordPaymentCheckAction({
token: 'abc', action: 'foo',
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the token is not on file', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 404 });
});
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: new Date(),
expires_at: new Date(Date.now() + 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
});
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() - 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
});
it('rejects partial with amount <= 0', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects partial with amount > outstanding', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
})).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('invoiceService.queuePaymentCheckEmail', () => {
beforeEach(() => resetChains());
it('skips when invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'not_found' });
});
it('skips when status is not sent/overdue', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'paid',
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res.sent).toBe(false);
expect(res.reason).toMatch(/wrong_status_paid/);
});
it('respects the 24h throttle', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
});
it('bypasses the throttle when skipThrottle=true', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
customer_account_id: 5,
created_by_admin_id: 42,
total_amount_minor: 10000,
currency: 'CHF',
language: 'de',
reminder_level: 0,
due_date: '2026-05-01',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: '[email protected]', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 5, email: '[email protected]', display_name: 'Test' };
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
expect(res.sent).toBe(true);
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
});
});
@@ -0,0 +1,236 @@
/**
* Tests for the createBaseDocument + getPageMetrics helpers — the
* shared PDF factory used by quote/invoice rendering AND by the
* upcoming tax-report renderer. These verify orientation handling
* and font defaults without touching DB or filesystem.
*/
const pdfService = require('../../src/services/pdfService');
describe('getPageMetrics', () => {
it('returns portrait A4 metrics by default', () => {
const p = pdfService.getPageMetrics();
expect(p.width).toBeCloseTo(595.28, 1);
expect(p.height).toBeCloseTo(841.89, 1);
expect(p.contentWidth).toBeCloseTo(515.28, 1);
});
it('returns portrait when orientation is "portrait"', () => {
const p = pdfService.getPageMetrics('portrait');
expect(p.width).toBeLessThan(p.height);
});
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
const p = pdfService.getPageMetrics('landscape');
expect(p.width).toBeCloseTo(841.89, 1);
expect(p.height).toBeCloseTo(595.28, 1);
expect(p.contentWidth).toBeCloseTo(761.89, 1);
expect(p.width).toBeGreaterThan(p.height);
});
it('ignores unknown orientation values (falls back to portrait)', () => {
const p = pdfService.getPageMetrics('upside-down');
expect(p.width).toBeLessThan(p.height);
});
});
describe('createBaseDocument', () => {
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
const { doc, page, fonts } = pdfService.createBaseDocument();
expect(doc).toBeDefined();
expect(typeof doc.on).toBe('function');
expect(typeof doc.font).toBe('function');
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
});
it('produces a landscape document when orientation is "landscape"', () => {
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
expect(page.width).toBeGreaterThan(page.height);
// PDFKit stores the active page dims on doc.page.
expect(doc.page.width).toBeCloseTo(841.89, 1);
expect(doc.page.height).toBeCloseTo(595.28, 1);
});
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
const ended = new Promise((resolve) => doc.on('end', resolve));
doc.text('hello', 40, 40);
doc.end();
await ended;
const buf = Buffer.concat(chunks);
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: null },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('falls back to Helvetica when the custom TTF path does not exist', () => {
// No exception, no logger.error blow-up — just silent fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('registers a bundled font family when pdfFontFamily is set', () => {
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
// backend/assets/fonts/Inter/, so the resolver should pick 400
// for body and 700 for bold.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'Inter' },
});
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'NotARealFamily' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('strips path-traversal characters from pdfFontFamily', () => {
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: '../../etc/passwd' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
// The explicit upload is the priority-1 override. When the upload
// path is unusable (file missing) the family is consulted next.
// Here we set BOTH to invalid values and confirm Helvetica fallback
// — what matters is that the family DIDN'T get registered while a
// (failed) explicit path was being evaluated.
const { fonts } = pdfService.createBaseDocument({
issuer: {
pdfFontTtfPath: '/nonexistent/path/font.ttf',
pdfFontFamily: 'Inter',
},
});
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
// registers successfully. crm-body / crm-bold confirm a custom
// font won.
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('forwards PDF info metadata (Title, Author) to the document', () => {
const { doc } = pdfService.createBaseDocument({
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
});
// PDFKit copies these onto doc.info during construction.
expect(doc.info.Title).toBe('Tax Report 2026');
expect(doc.info.Author).toBe('picpeak');
});
});
describe('exported letterhead helper', () => {
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
expect(typeof pdfService.drawIssuerBlock).toBe('function');
});
});
// Storno rendering — smoke-tests that exercise the kind='storno'
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
// directly for German strings because PDFKit Flate-compresses
// content streams, but we CAN verify the renderer:
// - completes without throwing on a Storno-shaped context,
// - produces a valid %PDF magic header,
// - produces a SMALLER document than its invoice counterpart
// (no payment block, no QR slip → fewer bytes), proving the
// suppression branches actually fire.
//
// Visual correctness (title swap, reference line, signed totals) is
// validated by manual review of a real Storno PDF; the renderer's
// branch logic is unit-tested in service tests where the inputs
// can be asserted directly.
describe('renderInvoiceToBuffer — Storno branch', () => {
function buildContext(overrides = {}) {
return {
locale: 'de',
currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
shippingAmountMinor: 0, totalAmountMinor: 32310,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
// Bank + payment term are part of the baseline invoice so the
// payment block renders a real IBAN + Zahlungsbedingungen
// section. The Storno branch suppresses this entirely, which
// produces a visible byte-size delta.
bank: {
accountHolder: 'AcmeCo',
iban: 'CH9300762011623852957',
bic: 'POFICHBE',
currency: 'CHF',
},
qrFormat: 'none',
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
...overrides,
};
}
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
// Baseline: normal invoice with a payment block.
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
// Storno: same context but kind='storno' → payment block + QR
// both suppressed. Payment block alone is ~80pt tall in the
// PDF; its absence is reliably detectable as a byte-size delta.
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(stornoBuf.length).toBeGreaterThan(0);
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
@@ -0,0 +1,95 @@
/**
* Pure-function tests for the PDF rendering helpers. These are the
* functions that DON'T touch PDFKit / DB — formatting, salutation
* routing, EPC payload construction.
*
* The helpers aren't directly exported from pdfService.js (it
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
* them via `_internal` which the module already exposes for tests.
*/
const pdfService = require('../../src/services/pdfService');
const { formatMinor, formatDate, t } = pdfService._internal;
describe('formatMinor', () => {
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
// de-CH uses (U+2019) as the thousands separator.
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[',\u2019]?234\.56/);
});
it('formats large amounts with thousands separators', () => {
// 12345600 minor units = 123,456.00 major; the separator
// varies by locale (de-CH = U+2019, en-GB = ',').
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[',\u2019]456\.00/);
});
it('returns 0,00 for zero or null', () => {
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
});
it('returns 2-decimal output regardless of locale', () => {
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
});
});
describe('formatDate', () => {
// formatDate now respects ctx.dateFormat (object with `format`
// key) — when omitted defaults to DD.MM.YYYY.
it('defaults to DD.MM.YYYY when no format passed', () => {
expect(formatDate('2026-04-19')).toBe('19.04.2026');
});
it('honors the configured DD/MM/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
});
it('honors the configured MM/DD/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
});
it('honors ISO YYYY-MM-DD', () => {
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
});
it('returns empty string on empty input', () => {
expect(formatDate('')).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
});
it('returns empty string on invalid input rather than throwing', () => {
expect(formatDate('not-a-date')).toBe('');
});
it('accepts Date objects', () => {
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
});
});
describe('t (i18n lookup)', () => {
it('returns the EN value for an EN-only locale', () => {
expect(t('en', 'invoice_title')).toBe('Invoice');
expect(t('en', 'quote_title')).toBe('Quote');
});
it('returns the DE value for de locale', () => {
expect(t('de', 'invoice_title')).toBe('Rechnung');
expect(t('de', 'quote_title')).toBe('Angebot');
});
it('falls back to EN for unknown locales', () => {
expect(t('xx', 'invoice_title')).toBe('Invoice');
});
it('substitutes named tokens like {percent}', () => {
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
expect(out).toMatch(/3% discount if paid within 5 working days\./);
});
it('falls back to EN when the key is missing on the requested locale', () => {
// page_of is seeded on all locales — pick something that
// exists on EN with a substitution.
const out = t('zz', 'page_of', { current: 1, total: 3 });
expect(out).toBe('Page 1 of 3');
});
});
@@ -0,0 +1,217 @@
/**
* 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');
});
});
@@ -0,0 +1,179 @@
/**
* Tests for quoteService lock + state-transition guards:
* - updateQuote refuses on accepted / declined / converted
* - adminAcceptQuote refuses on already-terminal states + atomic
* update path
*
* db deep-mocked, same chain pattern as invoiceService tests.
*/
const tableChains = {};
function makeChain() {
return {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
// knex chains are thenable; mirror that so `await trx('t')...`
// resolves to an array of rows.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._selectResult); }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
leftJoin: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
};
}
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const quoteService = require('../../src/services/quoteService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('quoteService.updateQuote — lock guards', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.updateQuote(99, {}, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('locks accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
};
// The lock check sits at the TOP of updateQuote. The
// observable behavior we care about is "no QUOTE_LOCKED
// 409 thrown on these statuses". The full transaction
// path may resolve to anything (incl. undefined) since
// the test mocks the trx callback — that's fine.
let err = null;
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
catch (e) { err = e; }
if (err) {
// Any error other than the QUOTE_LOCKED guard is allowed
// (we're not exercising the full path here).
expect(err.code).not.toBe('QUOTE_LOCKED');
}
resetChains();
}
});
});
describe('quoteService.adminAcceptQuote', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.adminAcceptQuote(99, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('refuses already-accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
});
it('refuses declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
});
it('refuses converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
});
it('accepts draft / sent / expired and returns lockedAt', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, customer_account_id: 5,
currency: 'CHF', language: 'de',
quote_number: 'Q-2026-0001',
total_amount_minor: 10000,
event_name: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, email: '[email protected]', display_name: 'Test',
};
pickChainFor('quote_line_items')._selectResult = [];
pickChainFor('business_profile')._firstValue = null;
const result = await quoteService.adminAcceptQuote(1, 42);
expect(result.status).toBe('accepted');
expect(result.lockedAt).toBeInstanceOf(Date);
resetChains();
}
});
});
@@ -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);
});
});
@@ -0,0 +1,338 @@
/**
* Tests for taxReportService.
*
* Two layers:
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
* buildCustomerLabel) — no db mock needed.
* 2. getTaxReport — db chain deep-mocked so we can feed canned
* invoice rows and assert the filter/bucket/total math.
*/
// ----- mock db chain ---------------------------------------------------
//
// taxReportService builds a single chain:
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
// .where(...).whereIn(...).orderBy(...).select(...)
// and then for cancelled ids:
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
//
// We use one shared chain factory that returns canned rows from
// `_selectResult` for the main query, and lets us swap the result
// for the replacements lookup via a "second-call" hook.
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
const c = {
_rows: initialRows,
then: function (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); }),
};
return c;
}
const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]);
// First call: main listing. Second call: replacements lookup.
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 and returns the mocked rows, so the raw() return value
// just needs to exist — a string is fine.
mockDbFn.raw = jest.fn((sql) => sql);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
const taxReportService = require('../../src/services/taxReportService');
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
// ----- pure helpers ----------------------------------------------------
describe('grossUpLateFee', () => {
it('returns zeros for a zero or negative fee', () => {
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
});
it('returns the whole fee as net when VAT rate is 0', () => {
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
// Missing/invalid rate is treated the same.
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
});
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
});
it('guarantees net + vat === gross input (no rounding drift)', () => {
for (const fee of [1, 2500, 9999, 12345, 250000]) {
for (const rate of [7.7, 8.1, 19, 20.5]) {
const { net, vat } = grossUpLateFee(fee, rate);
expect(net + vat).toBe(fee);
}
}
});
});
describe('computeReportedAmounts', () => {
it('returns stored amounts unchanged when late fee is zero', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 10770,
late_fee_amount_minor: 0,
vat_rate: 7.7,
});
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
});
it('adds the late-fee net/vat split onto the stored net + vat', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
late_fee_amount_minor: 2500,
vat_rate: 7.7,
});
expect(r.netMinor).toBe(10000 + 2321);
expect(r.vatMinor).toBe(770 + 179);
expect(r.totalMinor).toBe(13270);
});
it('keeps total at the stored total even when late fee is present', () => {
// The stored total already includes the late fee — we never
// recompute it from net + vat in the report.
const r = computeReportedAmounts({
net_amount_minor: 50000,
vat_amount_minor: 4050,
total_amount_minor: 56550,
late_fee_amount_minor: 2500,
vat_rate: 8.1,
});
expect(r.totalMinor).toBe(56550);
});
});
describe('buildCustomerLabel', () => {
it('prefers company_name when present', () => {
expect(buildCustomerLabel({
customer_company_name: 'ACME GmbH',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
customer_email: '[email protected]',
})).toBe('ACME GmbH');
});
it('falls back to first + last name', () => {
expect(buildCustomerLabel({
customer_company_name: '',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
})).toBe('Anna Beispiel');
});
it('falls back to display_name when no name parts', () => {
expect(buildCustomerLabel({
customer_display_name: 'Anna B.',
})).toBe('Anna B.');
});
it('falls back to email as a last resort', () => {
expect(buildCustomerLabel({ customer_email: '[email protected]' })).toBe('[email protected]');
});
it('returns empty string when nothing usable is present', () => {
expect(buildCustomerLabel({})).toBe('');
});
});
// ----- getTaxReport ----------------------------------------------------
describe('getTaxReport', () => {
it('throws when from/to or currency are missing', async () => {
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
.rejects.toThrow(/currency/);
});
it('returns rows + totals for a clean period with one paid invoice', async () => {
invoiceRowsForRun = [
{
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: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
});
expect(out.currency).toBe('CHF');
expect(out.rows).toHaveLength(1);
expect(out.rows[0]).toMatchObject({
invoiceNumber: 'R-2026-0001',
isCancelled: false,
customerLabel: 'ACME GmbH',
eventName: 'Wedding A',
netMinor: 10000,
vatMinor: 770,
totalMinor: 10770,
});
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(0);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
});
it('keeps cancelled rows visible but excludes them from totals', async () => {
invoiceRowsForRun = [
{
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', 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: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
{
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
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: 10,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
// The supersedes lookup query: row 11 supersedes row 10.
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toHaveLength(2);
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
expect(cancelled.isCancelled).toBe(true);
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
expect(replacement.isCancelled).toBe(false);
// Totals: only the replacement counts.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(1);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
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: 'A', event_name: 'X',
},
{
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'B', event_name: 'Y',
},
{
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
currency: 'CHF', status: 'sent', vat_rate: 8.1,
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'C', event_name: 'Z',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.totalsByVatRate).toHaveLength(2);
// Sorted ascending by rate.
expect(out.totalsByVatRate[0]).toEqual({
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
});
expect(out.totalsByVatRate[1]).toEqual({
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
});
expect(out.grandTotalNet).toBe(35000);
expect(out.grandTotalVat).toBe(2795);
expect(out.grandTotal).toBe(37795);
});
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
late_fee_amount_minor: 2500, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
expect(out.rows[0].netMinor).toBe(12321);
expect(out.rows[0].vatMinor).toBe(949);
expect(out.rows[0].totalMinor).toBe(13270);
// Grand totals reflect the same gross-up math.
expect(out.grandTotalNet).toBe(12321);
expect(out.grandTotalVat).toBe(949);
expect(out.grandTotal).toBe(13270);
});
it('returns empty rows + zero totals when no invoices match the period', async () => {
invoiceRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toEqual([]);
expect(out.grandTotalNet).toBe(0);
expect(out.grandTotalVat).toBe(0);
expect(out.grandTotal).toBe(0);
expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0);
});
});
+124
View File
@@ -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);
});
});
+121
View File
@@ -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');
});
});