d543949188
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
375 lines
15 KiB
JavaScript
375 lines
15 KiB
JavaScript
/**
|
|
* 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: 'admin@example.com', username: 'admin' };
|
|
pickChainFor('business_profile')._firstValue = null;
|
|
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
|
|
|
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
|
|
expect(res.sent).toBe(true);
|
|
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
});
|