feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
This commit is contained in:
@@ -0,0 +1,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:00–11:30 (2.50h): Editing wedding photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the colon when no description', () => {
|
||||||
|
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
|
||||||
|
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quantity is decimal hours with 2 places', () => {
|
||||||
|
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||||
|
expect(li.quantity).toBeCloseTo(2.5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('line_total rounds correctly for non-clean durations', () => {
|
||||||
|
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
|
||||||
|
const li = buildLineItemFromEntry(
|
||||||
|
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
expect(li.line_total_minor).toBe(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero-rate line items produce a zero total without exploding', () => {
|
||||||
|
const li = buildLineItemFromEntry(baseEntry, 0);
|
||||||
|
expect(li.line_total_minor).toBe(0);
|
||||||
|
expect(li.unit_price_minor).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the ISO 13616 IBAN validator.
|
||||||
|
*
|
||||||
|
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
|
||||||
|
* — they are publicly published sample values used by every IBAN
|
||||||
|
* implementation as test vectors. NOT real account numbers.
|
||||||
|
*/
|
||||||
|
const { validateIban, _internal } = require('../../src/utils/iban');
|
||||||
|
|
||||||
|
describe('validateIban', () => {
|
||||||
|
it('accepts a canonical Swiss IBAN', () => {
|
||||||
|
const out = validateIban('CH9300762011623852957');
|
||||||
|
expect(out.valid).toBe(true);
|
||||||
|
expect(out.normalized).toBe('CH9300762011623852957');
|
||||||
|
expect(out.reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Liechtenstein IBAN', () => {
|
||||||
|
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a German IBAN', () => {
|
||||||
|
expect(validateIban('DE89370400440532013000').valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an Austrian IBAN', () => {
|
||||||
|
expect(validateIban('AT611904300234573201').valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a British IBAN with alphanumeric BBAN', () => {
|
||||||
|
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises spaces and lowercase input', () => {
|
||||||
|
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
|
||||||
|
expect(out.valid).toBe(true);
|
||||||
|
expect(out.normalized).toBe('CH9300762011623852957');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises mixed-case input', () => {
|
||||||
|
const out = validateIban('ch9300762011623852957');
|
||||||
|
expect(out.valid).toBe(true);
|
||||||
|
expect(out.normalized).toBe('CH9300762011623852957');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty / null / undefined value', () => {
|
||||||
|
expect(validateIban('').reason).toBe('EMPTY');
|
||||||
|
expect(validateIban(' ').reason).toBe('EMPTY');
|
||||||
|
expect(validateIban(null).reason).toBe('EMPTY');
|
||||||
|
expect(validateIban(undefined).reason).toBe('EMPTY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed string (numbers in the country slot)', () => {
|
||||||
|
const out = validateIban('12930076201162385295');
|
||||||
|
expect(out.valid).toBe(false);
|
||||||
|
expect(out.reason).toBe('FORMAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a too-short string', () => {
|
||||||
|
expect(validateIban('CH93').reason).toBe('FORMAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a too-long string (over 34 chars)', () => {
|
||||||
|
// 35 chars: pads beyond the ISO 13616 max
|
||||||
|
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a known-country IBAN with the wrong length', () => {
|
||||||
|
// CH must be 21 chars; this one is 22.
|
||||||
|
const out = validateIban('CH9300762011623852957X');
|
||||||
|
expect(out.valid).toBe(false);
|
||||||
|
expect(out.reason).toBe('LENGTH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an IBAN with a broken checksum', () => {
|
||||||
|
// Same shape, last digit altered.
|
||||||
|
const out = validateIban('CH9300762011623852950');
|
||||||
|
expect(out.valid).toBe(false);
|
||||||
|
expect(out.reason).toBe('CHECKSUM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an IBAN with internally invalid characters', () => {
|
||||||
|
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an unknown-country IBAN that meets the generic length range', () => {
|
||||||
|
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
|
||||||
|
// structural regex passes if length is in [15, 34] and the
|
||||||
|
// checksum holds. Build a checksum-valid string:
|
||||||
|
//
|
||||||
|
// Format: ZZ + check + BBAN. We don't have a real ZZ template
|
||||||
|
// so this test just confirms unknown country codes route
|
||||||
|
// through the fallback length check rather than failing on
|
||||||
|
// LENGTH outright. A checksum-failing ZZ value will hit
|
||||||
|
// CHECKSUM, not LENGTH, which is the assertion below.
|
||||||
|
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
|
||||||
|
expect(out.valid).toBe(false);
|
||||||
|
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mod97', () => {
|
||||||
|
it('returns 1 for the canonical CH test vector', () => {
|
||||||
|
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns something other than 1 for a tampered IBAN', () => {
|
||||||
|
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IBAN_LENGTHS table', () => {
|
||||||
|
it('has the expected lengths for the most common European countries', () => {
|
||||||
|
// Sanity check that the table didn't drift if someone edits it.
|
||||||
|
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
|
||||||
|
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
|
||||||
|
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
|
||||||
|
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
|
||||||
|
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
|
||||||
|
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
|
||||||
|
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
|
||||||
|
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Pure-function tests for the PDF filename builder used on every
|
||||||
|
* quote / invoice download endpoint + the PDF's internal Title
|
||||||
|
* metadata. No mocks needed — all behavior is deterministic.
|
||||||
|
*/
|
||||||
|
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
|
||||||
|
|
||||||
|
describe('sanitiseSegment', () => {
|
||||||
|
it('returns empty string for null/undefined', () => {
|
||||||
|
expect(sanitiseSegment(null)).toBe('');
|
||||||
|
expect(sanitiseSegment(undefined)).toBe('');
|
||||||
|
expect(sanitiseSegment('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces filesystem-hostile characters with "-"', () => {
|
||||||
|
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses spaces into single "-"', () => {
|
||||||
|
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses repeat dashes', () => {
|
||||||
|
expect(sanitiseSegment('a-----b')).toBe('a-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading + trailing dashes/dots', () => {
|
||||||
|
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-ASCII letters', () => {
|
||||||
|
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps length at 80 chars by default', () => {
|
||||||
|
const long = 'a'.repeat(120);
|
||||||
|
expect(sanitiseSegment(long)).toHaveLength(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors custom maxLen', () => {
|
||||||
|
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customerLabel', () => {
|
||||||
|
it('prefers company_name over person name', () => {
|
||||||
|
expect(customerLabel({
|
||||||
|
company_name: 'ACME GmbH',
|
||||||
|
first_name: 'Luca', last_name: 'Bresch',
|
||||||
|
})).toBe('ACME-GmbH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first + last when company_name is empty', () => {
|
||||||
|
expect(customerLabel({
|
||||||
|
company_name: '',
|
||||||
|
first_name: 'Luca', last_name: 'Bresch',
|
||||||
|
})).toBe('Luca-Bresch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to display_name when no company + no person', () => {
|
||||||
|
expect(customerLabel({
|
||||||
|
display_name: 'Luca B.',
|
||||||
|
})).toBe('Luca-B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to email local-part as a last resort', () => {
|
||||||
|
expect(customerLabel({
|
||||||
|
email: '[email protected]',
|
||||||
|
})).toBe('luca');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "customer" when everything is missing', () => {
|
||||||
|
expect(customerLabel({})).toBe('customer');
|
||||||
|
expect(customerLabel(null)).toBe('customer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace before evaluating truthiness', () => {
|
||||||
|
// company_name = " " should NOT trigger the company branch.
|
||||||
|
expect(customerLabel({
|
||||||
|
company_name: ' ',
|
||||||
|
first_name: 'Luca', last_name: 'Bresch',
|
||||||
|
})).toBe('Luca-Bresch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPdfFilename', () => {
|
||||||
|
const customer = { company_name: 'ACME GmbH' };
|
||||||
|
|
||||||
|
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
|
||||||
|
expect(buildPdfFilename({
|
||||||
|
docNumber: 'R-2026-0001',
|
||||||
|
customer,
|
||||||
|
})).toBe('R-2026-0001_ACME-GmbH.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the fallback when docNumber is null (preview)', () => {
|
||||||
|
expect(buildPdfFilename({
|
||||||
|
docNumber: null,
|
||||||
|
customer,
|
||||||
|
fallback: 'invoice-preview',
|
||||||
|
})).toBe('invoice-preview_ACME-GmbH.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "document" when both docNumber + fallback are absent', () => {
|
||||||
|
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitises the customer half too', () => {
|
||||||
|
expect(buildPdfFilename({
|
||||||
|
docNumber: 'R-2026-0001',
|
||||||
|
customer: { company_name: 'Bad/Name:Inc.' },
|
||||||
|
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always ends with .pdf', () => {
|
||||||
|
expect(buildPdfFilename({
|
||||||
|
docNumber: 'R-2026-0001',
|
||||||
|
customer: {},
|
||||||
|
})).toMatch(/\.pdf$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* resolveLogoFile — verifies the path-priority chain + the
|
||||||
|
* unsupported-format guard. fs + appSettings + storage config are
|
||||||
|
* mocked so the test is fully deterministic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock('../../src/utils/appSettings', () => ({
|
||||||
|
getAppSetting: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/config/storage', () => ({
|
||||||
|
getStoragePath: jest.fn(() => '/app/storage'),
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/logger', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
|
||||||
|
const { getAppSetting } = require('../../src/utils/appSettings');
|
||||||
|
|
||||||
|
describe('resolveLogoFile', () => {
|
||||||
|
let existsSpy, statSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
existsSpy = jest.spyOn(fs, 'existsSync');
|
||||||
|
statSpy = jest.spyOn(fs, 'statSync');
|
||||||
|
existsSpy.mockReturnValue(false);
|
||||||
|
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||||
|
getAppSetting.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
existsSpy.mockRestore();
|
||||||
|
statSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no sources are configured', async () => {
|
||||||
|
getAppSetting.mockResolvedValue(null);
|
||||||
|
const out = await resolveLogoFile({});
|
||||||
|
expect(out).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers business_profile.logo_path over branding fallbacks', async () => {
|
||||||
|
// The profile path exists, branding doesn't.
|
||||||
|
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
|
||||||
|
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
|
||||||
|
const out = await resolveLogoFile({
|
||||||
|
logo_path: 'uploads/logos/profile.png',
|
||||||
|
});
|
||||||
|
expect(out).toBe('/app/storage/uploads/logos/profile.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to branding_logo_path when profile is empty', async () => {
|
||||||
|
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||||
|
getAppSetting.mockImplementation(async (key) => {
|
||||||
|
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const out = await resolveLogoFile({ logo_path: '' });
|
||||||
|
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
|
||||||
|
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||||
|
getAppSetting.mockImplementation(async (key) => {
|
||||||
|
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const out = await resolveLogoFile({});
|
||||||
|
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips SVG (PDFKit cannot embed)', async () => {
|
||||||
|
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
|
||||||
|
getAppSetting.mockResolvedValue(null);
|
||||||
|
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
|
||||||
|
expect(out).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also rejects WebP / GIF / TIFF', async () => {
|
||||||
|
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
|
||||||
|
existsSpy.mockReturnValue(true);
|
||||||
|
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||||
|
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||||
|
getAppSetting.mockResolvedValue(null);
|
||||||
|
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||||
|
expect(out).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts PNG and JPEG', async () => {
|
||||||
|
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
|
||||||
|
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||||
|
getAppSetting.mockResolvedValue(null);
|
||||||
|
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||||
|
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats absolute paths as-is when they exist', async () => {
|
||||||
|
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
|
||||||
|
getAppSetting.mockResolvedValue(null);
|
||||||
|
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
|
||||||
|
expect(out).toBe('/abs/path/logo.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+420
-30
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.42.2-beta.0",
|
"version": "3.47.2-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.42.2-beta.0",
|
"version": "3.47.2-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -38,13 +38,17 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"swissqrbill": "^4.3.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"winston": "^3.8.2",
|
"winston": "^3.8.2",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
@@ -308,7 +312,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
||||||
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha1-browser": "5.2.0",
|
"@aws-crypto/sha1-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
@@ -1037,7 +1040,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -2705,6 +2707,24 @@
|
|||||||
"@noble/hashes": "^1.1.5"
|
"@noble/hashes": "^1.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@scarf/scarf": {
|
"node_modules/@scarf/scarf": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||||
@@ -3548,6 +3568,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz",
|
||||||
@@ -3740,7 +3769,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4263,6 +4291,24 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -4283,7 +4329,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4484,7 +4529,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4651,6 +4695,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -4946,6 +4999,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -4964,6 +5023,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -5084,6 +5152,12 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/diff-sequences": {
|
"node_modules/diff-sequences": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||||
@@ -5094,6 +5168,12 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
@@ -5376,7 +5456,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -5624,7 +5703,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -5767,7 +5845,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -6014,6 +6091,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -6228,7 +6322,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -6577,7 +6670,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -6608,19 +6700,6 @@
|
|||||||
"cross-fetch": "4.1.0"
|
"cross-fetch": "4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -7607,6 +7686,13 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jpeg-exif": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -7910,6 +7996,25 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -9054,7 +9159,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -9066,6 +9170,12 @@
|
|||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -9117,7 +9227,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -9178,6 +9287,37 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||||
|
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"jpeg-exif": "^1.1.4",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.16.3",
|
"version": "8.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
@@ -9371,6 +9511,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"browserify-zlib": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
@@ -9618,6 +9775,161 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -9782,12 +10094,17 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -9851,6 +10168,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
@@ -10030,8 +10353,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -10750,6 +11072,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-engine": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-engine/-/svg-engine-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-s172jAcwfoCcvM/6DwNBvmWN3brztHGFENCR+RU3CBJKeBxPrRlTltVxX1Je5hst782QgP8PM6U37vUR/RhPng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/swagger-jsdoc": {
|
"node_modules/swagger-jsdoc": {
|
||||||
"version": "6.2.8",
|
"version": "6.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||||
@@ -10815,6 +11143,30 @@
|
|||||||
"express": ">=4.0.0 || >=5.0.0-beta"
|
"express": ">=4.0.0 || >=5.0.0-beta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swissqrbill": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/swissqrbill/-/swissqrbill-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-FzSPEVWVQ3R6B0vghqi7VJCLp54AMYxCSiJGr2QzXo8OSU8JBv7XJcF67BAVAriGkuaZqVfhxuD6yRFT6WAXEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-engine": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pdfkit": ">=0.13.0",
|
||||||
|
"typescript": ">=4.7.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pdfkit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.13",
|
"version": "7.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||||
@@ -10941,6 +11293,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmpl": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@@ -11103,6 +11461,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unique-filename": {
|
"node_modules/unique-filename": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
|
||||||
@@ -11284,6 +11668,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wide-align": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
|||||||
@@ -44,13 +44,17 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"swissqrbill": "^4.3.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"winston": "^3.8.2",
|
"winston": "^3.8.2",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
|
|||||||
+49
-4
@@ -20,6 +20,7 @@ const path = require('path');
|
|||||||
const { initializeDatabase, db } = require('./src/database/db');
|
const { initializeDatabase, db } = require('./src/database/db');
|
||||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||||
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||||
|
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
||||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||||
const { startBackupService } = require('./src/services/backupService');
|
const { startBackupService } = require('./src/services/backupService');
|
||||||
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||||
@@ -46,9 +47,28 @@ const secureImagesRoutes = require('./src/routes/secureImages');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// Trust proxy headers (required for Traefik/nginx)
|
// Trust proxy headers (required for Traefik/nginx).
|
||||||
// Set to specific number of proxies or loopback to be more secure
|
//
|
||||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
// `req.ip` is computed by Express by walking X-Forwarded-For from
|
||||||
|
// right-to-left and stopping at the first hop NOT in this list, so
|
||||||
|
// the value picpeak audits (signing IPs, payment-check actions,
|
||||||
|
// rate-limit keys) is the originating client IP behind any number
|
||||||
|
// of trusted reverse proxies.
|
||||||
|
//
|
||||||
|
// Default: 'loopback, linklocal, uniquelocal' — covers localhost,
|
||||||
|
// link-local (169.254.0.0/16), and unique-local IPv6 (fc00::/7).
|
||||||
|
// Standard for nginx-in-front-of-Node deployments on the same host
|
||||||
|
// and for Docker bridge networks. Operators with unusual topologies
|
||||||
|
// (load balancer in a public subnet, multi-hop NAT) override via
|
||||||
|
// TRUST_PROXY env, accepting any value Express accepts: a number,
|
||||||
|
// 'loopback', 'linklocal', 'uniquelocal', a CIDR, a comma list, or
|
||||||
|
// 'true' (trust ALL proxies — only safe behind a fully-controlled
|
||||||
|
// reverse-proxy chain).
|
||||||
|
//
|
||||||
|
// NEVER read req.headers['x-forwarded-for'] directly in audit paths
|
||||||
|
// — see utils/clientIp.js for the rationale.
|
||||||
|
const trustProxySetting = process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal';
|
||||||
|
app.set('trust proxy', trustProxySetting === 'true' ? true : trustProxySetting);
|
||||||
|
|
||||||
// Security middleware with custom CSP
|
// Security middleware with custom CSP
|
||||||
// In native HTTP installs, do NOT force HTTPS for subresources.
|
// In native HTTP installs, do NOT force HTTPS for subresources.
|
||||||
@@ -620,9 +640,30 @@ app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
|||||||
const { noStoreCache } = require('./src/middleware/noStoreCache');
|
const { noStoreCache } = require('./src/middleware/noStoreCache');
|
||||||
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
|
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
|
||||||
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
||||||
// distinct token type, distinct cookie, distinct middleware.
|
// distinct token type, distinct cookie, distinct middleware. The
|
||||||
|
// noStoreCache wrapper (upstream) prevents stale customer-portal
|
||||||
|
// data from being served after logout. The CRM-area route-flag
|
||||||
|
// gate was reverted upstream and lives in the UI now.
|
||||||
app.use('/api/customer/auth', noStoreCache, require('./src/routes/customerAuth'));
|
app.use('/api/customer/auth', noStoreCache, require('./src/routes/customerAuth'));
|
||||||
app.use('/api/customer', noStoreCache, require('./src/routes/customer'));
|
app.use('/api/customer', noStoreCache, require('./src/routes/customer'));
|
||||||
|
|
||||||
|
// --- CRM (#TBD) -------------------------------------------------------
|
||||||
|
// Quotes / Invoices / Contracts / Calendar / Tax report / Deals lineage.
|
||||||
|
// Business profile (issuer block for PDFs) lives at
|
||||||
|
// /api/admin/business-profile, gated by the existing settings.manage
|
||||||
|
// permission rather than a CRM-specific one. The public endpoints
|
||||||
|
// host the customer-side accept/decline / sign / payment-check pages.
|
||||||
|
app.use('/api/admin/business-profile', require('./src/routes/adminBusinessProfile'));
|
||||||
|
app.use('/api/admin/quotes', require('./src/routes/adminQuotes'));
|
||||||
|
app.use('/api/admin/invoices', require('./src/routes/adminInvoices'));
|
||||||
|
app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
|
||||||
|
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
|
||||||
|
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
|
||||||
|
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
|
||||||
|
app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
||||||
|
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
||||||
|
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
|
||||||
|
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck'));
|
||||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||||
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||||
@@ -729,6 +770,10 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start expiration checker
|
// Start expiration checker
|
||||||
startExpirationChecker();
|
startExpirationChecker();
|
||||||
|
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
||||||
|
// + run the overdue reminder ladder. No-op when the `bills` feature
|
||||||
|
// flag is OFF (the service short-circuits on empty result sets).
|
||||||
|
startInvoiceScheduler();
|
||||||
|
|
||||||
// Initialize email transporter and start queue processor
|
// Initialize email transporter and start queue processor
|
||||||
await initializeTransporter();
|
await initializeTransporter();
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Business Profile Routes
|
||||||
|
*
|
||||||
|
* Endpoint mounted at /api/admin/business-profile (see server.js wiring).
|
||||||
|
* Issuer block + bank-account roster that every quote/invoice PDF pulls
|
||||||
|
* from. Gated by the existing `settings.edit` permission so any admin
|
||||||
|
* who can edit Settings can edit this too — no separate CRM permission
|
||||||
|
* required at this layer.
|
||||||
|
*
|
||||||
|
* Logo upload is delegated to the shared branding-upload helper at
|
||||||
|
* /api/admin/branding/upload-logo and we just store the returned URL on
|
||||||
|
* business_profile.logo_path; that route already has the multer +
|
||||||
|
* resize stack we'd otherwise duplicate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param } = require('express-validator');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { getStoragePath } = require('../config/storage');
|
||||||
|
const businessProfileService = require('../services/businessProfileService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { validateIban } = require('../utils/iban');
|
||||||
|
const { validationResult } = require('express-validator');
|
||||||
|
const { ValidationError } = require('../utils/errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same shape as utils/routeHelpers.validateRequest BUT surfaces the
|
||||||
|
* FIRST field-level error message as the top-level `error` string —
|
||||||
|
* so a user typing a bad IBAN sees "IBAN checksum is invalid — please
|
||||||
|
* check for typos" in the toast, not the generic "Validation failed".
|
||||||
|
*
|
||||||
|
* Scoped to this route file because business-profile is the only
|
||||||
|
* surface where field-specific copy is worth the extra wiring;
|
||||||
|
* other routes keep the shared helper's behaviour.
|
||||||
|
*/
|
||||||
|
function validateRequestWithFieldMessage(req) {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (errors.isEmpty()) return;
|
||||||
|
const details = errors.array().map((err) => ({
|
||||||
|
field: err.path || err.param,
|
||||||
|
message: err.msg,
|
||||||
|
}));
|
||||||
|
// Use the first field-level message as the top-level message so
|
||||||
|
// generic toast UIs that only read `error` still get the precise
|
||||||
|
// reason. Falls back to "Validation failed" only when no message
|
||||||
|
// was supplied (shouldn't happen with our validators).
|
||||||
|
const primary = details[0]?.message || 'Validation failed';
|
||||||
|
throw new ValidationError(primary, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* express-validator custom rule that runs the ISO 13616 IBAN check
|
||||||
|
* AND normalises the value on the request body so the service-layer
|
||||||
|
* insert/update stores the canonical spaceless uppercase form. Lets
|
||||||
|
* admins paste IBANs with spaces ("CH93 0076 ...") without the
|
||||||
|
* uniqueness/render code having to re-normalise downstream.
|
||||||
|
*
|
||||||
|
* Used by both POST and PUT /bank-accounts. Pass `required: true` on
|
||||||
|
* POST (IBAN is mandatory there) and `required: false` on PUT (admin
|
||||||
|
* may be patching other fields without touching the IBAN).
|
||||||
|
*/
|
||||||
|
function ibanValidator({ required }) {
|
||||||
|
return (value, { req }) => {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
if (required) throw new Error('IBAN is required');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = validateIban(value);
|
||||||
|
if (!result.valid) {
|
||||||
|
const reasonText = {
|
||||||
|
EMPTY: 'IBAN is required',
|
||||||
|
FORMAT: 'IBAN format is invalid (expected country code + check digits + account)',
|
||||||
|
LENGTH: 'IBAN has the wrong length for this country',
|
||||||
|
CHECKSUM: 'IBAN checksum is invalid — please check for typos',
|
||||||
|
}[result.reason] || 'IBAN is invalid';
|
||||||
|
throw new Error(reasonText);
|
||||||
|
}
|
||||||
|
// Persist the normalised value so the DB never sees a
|
||||||
|
// user-typed space.
|
||||||
|
req.body.iban = result.normalized;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Multer config for the dedicated PDF letterhead logo. Same target
|
||||||
|
// directory as the global branding upload (storage/uploads/logos)
|
||||||
|
// but accepts SVG in addition to PNG / JPEG — the PDF renderer
|
||||||
|
// rasterises SVGs to PNG on the fly via resolveLogoFile() so the
|
||||||
|
// admin can drop a vector logo here and have it work in print.
|
||||||
|
const pdfLogoStorage = multer.diskStorage({
|
||||||
|
destination: async (_req, _file, cb) => {
|
||||||
|
const dir = path.join(getStoragePath(), 'uploads/logos');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname) || '.png';
|
||||||
|
cb(null, `pdf-logo-${Date.now()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfLogoUpload = multer({
|
||||||
|
storage: pdfLogoStorage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||||
|
if (allowed.includes(file.mimetype)) cb(null, true);
|
||||||
|
else cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB-shape → API shape. Keep narrow so adding new DB columns doesn't
|
||||||
|
* silently leak through the API contract.
|
||||||
|
*/
|
||||||
|
function transformProfile(p) {
|
||||||
|
if (!p) return null;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
companyName: p.company_name || '',
|
||||||
|
addressLine1: p.address_line1 || '',
|
||||||
|
addressLine2: p.address_line2 || '',
|
||||||
|
postalCode: p.postal_code || '',
|
||||||
|
city: p.city || '',
|
||||||
|
state: p.state || '',
|
||||||
|
countryCode: p.country_code || '',
|
||||||
|
countryName: p.country_name || '',
|
||||||
|
phone: p.phone || '',
|
||||||
|
mobile: p.mobile || '',
|
||||||
|
email: p.email || '',
|
||||||
|
website: p.website || '',
|
||||||
|
vatId: p.vat_id || '',
|
||||||
|
// Steuernummer (migration 139). Distinct from VAT-ID; both can
|
||||||
|
// appear on the invoice issuer block to satisfy §14 UStG.
|
||||||
|
taxId: p.tax_id || '',
|
||||||
|
vatLabel: p.vat_label || 'MwSt.',
|
||||||
|
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
|
||||||
|
defaultCurrency: p.default_currency || 'CHF',
|
||||||
|
defaultLocale: p.default_locale || 'de',
|
||||||
|
defaultQrFormat: p.default_qr_format || 'none',
|
||||||
|
footerLine: p.footer_line || '',
|
||||||
|
logoPath: p.logo_path || '',
|
||||||
|
pdfFontTtfPath: p.pdf_font_ttf_path || '',
|
||||||
|
// Bundled-fonts dropdown (migration 121). NULL = no preference,
|
||||||
|
// Helvetica fallback. Surfaces the on-disk directory name (e.g.
|
||||||
|
// "Inter", "Playfair-Display"); pdfService maps it to the
|
||||||
|
// bundled TTFs at render time.
|
||||||
|
pdfFontFamily: p.pdf_font_family || null,
|
||||||
|
pdfShowLogo: p.pdf_show_logo == null ? true : (p.pdf_show_logo === true || p.pdf_show_logo === 1 || p.pdf_show_logo === '1'),
|
||||||
|
pdfShowCompanyName: p.pdf_show_company_name == null ? true : (p.pdf_show_company_name === true || p.pdf_show_company_name === 1 || p.pdf_show_company_name === '1'),
|
||||||
|
pdfFoldingMarks: p.pdf_folding_marks || 'none',
|
||||||
|
pdfLogoHeight: p.pdf_logo_height == null ? 56 : Number(p.pdf_logo_height),
|
||||||
|
pdfCompanyNameInline: p.pdf_company_name_inline === true || p.pdf_company_name_inline === 1 || p.pdf_company_name_inline === '1',
|
||||||
|
pdfQuoteShowNetDays: p.pdf_quote_show_net_days === true || p.pdf_quote_show_net_days === 1 || p.pdf_quote_show_net_days === '1',
|
||||||
|
pdfQuoteShowSkonto: p.pdf_quote_show_skonto === true || p.pdf_quote_show_skonto === 1 || p.pdf_quote_show_skonto === '1',
|
||||||
|
// Migration 137 — IANA timezone for the admin calendar. Null when
|
||||||
|
// the admin hasn't picked one; frontend falls back to the browser.
|
||||||
|
timezone: p.timezone || null,
|
||||||
|
createdAt: p.created_at,
|
||||||
|
updatedAt: p.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBank(b) {
|
||||||
|
if (!b) return null;
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
label: b.label || '',
|
||||||
|
accountHolder: b.account_holder || '',
|
||||||
|
iban: b.iban,
|
||||||
|
bic: b.bic || '',
|
||||||
|
currency: b.currency || '',
|
||||||
|
isDefault: b.is_default === 1 || b.is_default === true || b.is_default === '1',
|
||||||
|
displayOrder: b.display_order || 0,
|
||||||
|
createdAt: b.created_at,
|
||||||
|
updatedAt: b.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
// ---- GET / ------------------------------------------------------------
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requirePermission('settings.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const { profile, bankAccounts } = await businessProfileService.getProfile();
|
||||||
|
return successResponse(res, {
|
||||||
|
profile: transformProfile(profile),
|
||||||
|
bankAccounts: bankAccounts.map(transformBank),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- GET /logo-diagnostic ---------------------------------------------
|
||||||
|
// Diagnostic for "logo doesn't appear on PDF" tickets. Returns the
|
||||||
|
// configured logo sources (business_profile.logo_path,
|
||||||
|
// app_settings.branding_logo_path, app_settings.branding_logo_url),
|
||||||
|
// the storage root the renderer would use, the candidate paths the
|
||||||
|
// resolver would try, and which one (if any) currently resolves to
|
||||||
|
// an existing file. Read-only — never modifies anything.
|
||||||
|
router.get(
|
||||||
|
'/logo-diagnostic',
|
||||||
|
requirePermission('settings.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { getStoragePath } = require('../config/storage');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { resolveLogoFile } = require('../utils/resolveLogoFile');
|
||||||
|
|
||||||
|
const { profile } = await businessProfileService.getProfile();
|
||||||
|
const storageRoot = getStoragePath();
|
||||||
|
const brandingDiskPath = await getAppSetting('branding_logo_path');
|
||||||
|
const brandingLogoUrl = await getAppSetting('branding_logo_url');
|
||||||
|
const resolved = await resolveLogoFile(profile);
|
||||||
|
|
||||||
|
const inspect = (label, raw) => {
|
||||||
|
const value = (raw || '').toString().trim();
|
||||||
|
if (!value) return { label, value: null, candidates: [] };
|
||||||
|
const stripped = value.replace(/^\/+/, '');
|
||||||
|
const baseName = path.basename(value);
|
||||||
|
const candidates = [
|
||||||
|
path.isAbsolute(value) ? value : null,
|
||||||
|
path.join(storageRoot, stripped),
|
||||||
|
path.join(storageRoot, 'uploads', 'logos', baseName),
|
||||||
|
path.join(storageRoot, 'branding', baseName),
|
||||||
|
path.join(process.cwd(), 'storage', stripped),
|
||||||
|
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
|
||||||
|
path.join(process.cwd(), 'storage', 'branding', baseName),
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
label, value,
|
||||||
|
candidates: [...new Set(candidates)].map((p) => ({
|
||||||
|
path: p,
|
||||||
|
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
storageRoot,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
resolvedTo: resolved,
|
||||||
|
sources: [
|
||||||
|
inspect('business_profile.logo_path', profile?.logo_path),
|
||||||
|
inspect('app_settings.branding_logo_path', brandingDiskPath),
|
||||||
|
inspect('app_settings.branding_logo_url', brandingLogoUrl),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- POST /logo, DELETE /logo -----------------------------------------
|
||||||
|
// Dedicated PDF letterhead logo upload (separate from the global
|
||||||
|
// Settings → Branding logo). PNG, JPEG, and SVG accepted; the PDF
|
||||||
|
// renderer rasterises SVG to PNG via resolveLogoFile() so vector
|
||||||
|
// uploads work in print. The relative path is stored in
|
||||||
|
// business_profile.logo_path; the existing fallback to
|
||||||
|
// branding_logo_path still applies when this is unset.
|
||||||
|
router.post(
|
||||||
|
'/logo',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
pdfLogoUpload.single('logo'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the previous PDF logo on disk if it was uploaded via
|
||||||
|
// this same endpoint (matches the pdf-logo-* prefix). We leave
|
||||||
|
// anything else untouched — the admin may have set logo_path to
|
||||||
|
// a path managed by a different system.
|
||||||
|
try {
|
||||||
|
const previous = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
const prev = previous?.logo_path;
|
||||||
|
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
|
||||||
|
const stripped = prev.replace(/^\/+/, '');
|
||||||
|
const prevDisk = path.isAbsolute(prev)
|
||||||
|
? prev
|
||||||
|
: path.join(getStoragePath(), stripped);
|
||||||
|
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
const relative = `/uploads/logos/${req.file.filename}`;
|
||||||
|
await businessProfileService.updateProfile(
|
||||||
|
{ logo_path: relative },
|
||||||
|
req.admin.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return successResponse(res, { logoPath: relative }, 200, 'PDF logo uploaded');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/logo',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const existing = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
const prev = existing?.logo_path;
|
||||||
|
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
|
||||||
|
const stripped = prev.replace(/^\/+/, '');
|
||||||
|
const prevDisk = path.isAbsolute(prev)
|
||||||
|
? prev
|
||||||
|
: path.join(getStoragePath(), stripped);
|
||||||
|
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
await businessProfileService.updateProfile(
|
||||||
|
{ logo_path: '' },
|
||||||
|
req.admin.id
|
||||||
|
);
|
||||||
|
return successResponse(res, { cleared: true }, 200, 'PDF logo cleared');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- PUT / ------------------------------------------------------------
|
||||||
|
router.put(
|
||||||
|
'/',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[
|
||||||
|
// All fields optional — partial update is fine. We only run shallow
|
||||||
|
// shape validation on the types that absolutely must be sane;
|
||||||
|
// service layer does the trimming + currency/country normalisation.
|
||||||
|
body('companyName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('addressLine1').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('addressLine2').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('postalCode').optional({ values: 'falsy' }).isString().isLength({ max: 20 }),
|
||||||
|
body('city').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
|
||||||
|
body('state').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
|
||||||
|
body('countryCode').optional({ values: 'falsy' }).isString().isLength({ min: 2, max: 2 }),
|
||||||
|
body('countryName').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
|
||||||
|
body('phone').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
body('mobile').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
body('email').optional({ values: 'falsy' }).isEmail().withMessage('Invalid issuer email'),
|
||||||
|
body('website').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('vatId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
// Migration 139 — Steuernummer (DE/AT). Free-text up to 64 chars.
|
||||||
|
body('taxId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
body('vatLabel').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
body('vatRateDefault').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('defaultCurrency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
|
||||||
|
body('footerLine').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
|
||||||
|
// Bundled-fonts dropdown (migration 121). Free-text upload field
|
||||||
|
// (pdfFontTtfPath, migration 103) was retired from the UI in
|
||||||
|
// favour of this dropdown; the column stays in the DB so any
|
||||||
|
// legacy value continues to be honoured by pdfService.
|
||||||
|
body('pdfFontFamily').optional({ nullable: true, values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
// Visibility toggles use the explicit-undefined check pattern so
|
||||||
|
// `false` actually reaches the service layer. `optional({ values:
|
||||||
|
// 'falsy' })` would drop `false` and the toggle could never be
|
||||||
|
// disabled.
|
||||||
|
body('pdfShowLogo').optional().isBoolean(),
|
||||||
|
body('pdfShowCompanyName').optional().isBoolean(),
|
||||||
|
body('pdfCompanyNameInline').optional().isBoolean(),
|
||||||
|
body('pdfFoldingMarks').optional({ values: 'falsy' }).isIn(['none', 'half', 'third', 'both']),
|
||||||
|
body('pdfLogoHeight').optional({ values: 'falsy' }).isInt({ min: 24, max: 200 }),
|
||||||
|
body('pdfQuoteShowNetDays').optional().isBoolean(),
|
||||||
|
body('pdfQuoteShowSkonto').optional().isBoolean(),
|
||||||
|
// Migration 137 — admin calendar timezone (IANA string e.g.
|
||||||
|
// "Europe/Zurich"). Free-text; backend stores up to 64 chars.
|
||||||
|
// Frontend falls back to browser Intl when this is blank.
|
||||||
|
body('timezone').optional({ values: 'falsy', nullable: true }).isString().isLength({ max: 64 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
// Convert camelCase → snake_case for the service layer.
|
||||||
|
const payload = {};
|
||||||
|
const map = {
|
||||||
|
companyName: 'company_name',
|
||||||
|
addressLine1: 'address_line1',
|
||||||
|
addressLine2: 'address_line2',
|
||||||
|
postalCode: 'postal_code',
|
||||||
|
city: 'city',
|
||||||
|
state: 'state',
|
||||||
|
countryCode: 'country_code',
|
||||||
|
countryName: 'country_name',
|
||||||
|
phone: 'phone',
|
||||||
|
mobile: 'mobile',
|
||||||
|
email: 'email',
|
||||||
|
website: 'website',
|
||||||
|
vatId: 'vat_id',
|
||||||
|
taxId: 'tax_id',
|
||||||
|
vatLabel: 'vat_label',
|
||||||
|
vatRateDefault: 'vat_rate_default',
|
||||||
|
defaultCurrency: 'default_currency',
|
||||||
|
defaultLocale: 'default_locale',
|
||||||
|
defaultQrFormat: 'default_qr_format',
|
||||||
|
footerLine: 'footer_line',
|
||||||
|
logoPath: 'logo_path',
|
||||||
|
pdfFontFamily: 'pdf_font_family',
|
||||||
|
pdfShowLogo: 'pdf_show_logo',
|
||||||
|
pdfShowCompanyName: 'pdf_show_company_name',
|
||||||
|
pdfCompanyNameInline: 'pdf_company_name_inline',
|
||||||
|
pdfFoldingMarks: 'pdf_folding_marks',
|
||||||
|
pdfLogoHeight: 'pdf_logo_height',
|
||||||
|
pdfQuoteShowNetDays: 'pdf_quote_show_net_days',
|
||||||
|
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
|
||||||
|
// Migration 137 — admin calendar timezone.
|
||||||
|
timezone: 'timezone',
|
||||||
|
};
|
||||||
|
for (const [api, db] of Object.entries(map)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||||
|
payload[db] = req.body[api];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profile, bankAccounts } = await businessProfileService.updateProfile(
|
||||||
|
payload,
|
||||||
|
req.admin.id
|
||||||
|
);
|
||||||
|
return successResponse(res, {
|
||||||
|
profile: transformProfile(profile),
|
||||||
|
bankAccounts: bankAccounts.map(transformBank),
|
||||||
|
}, 200, 'Business profile updated');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- bank accounts ----------------------------------------------------
|
||||||
|
router.get(
|
||||||
|
'/bank-accounts',
|
||||||
|
requirePermission('settings.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const { bankAccounts } = await businessProfileService.getProfile();
|
||||||
|
return successResponse(res, { bankAccounts: bankAccounts.map(transformBank) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/bank-accounts',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[
|
||||||
|
body('iban').isString().isLength({ min: 5, max: 64 }).withMessage('IBAN is required')
|
||||||
|
.bail().custom(ibanValidator({ required: true })),
|
||||||
|
body('label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('accountHolder').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('bic').optional({ values: 'falsy' }).isString().isLength({ max: 16 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('isDefault').optional({ values: 'falsy' }).isBoolean(),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequestWithFieldMessage(req);
|
||||||
|
const bank = await businessProfileService.createBankAccount({
|
||||||
|
iban: req.body.iban,
|
||||||
|
label: req.body.label,
|
||||||
|
account_holder: req.body.accountHolder,
|
||||||
|
bic: req.body.bic,
|
||||||
|
currency: req.body.currency,
|
||||||
|
is_default: req.body.isDefault,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
}, req.admin.id);
|
||||||
|
return successResponse(res, { bankAccount: transformBank(bank) }, 201, 'Bank account created');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/bank-accounts/:id',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('iban').optional({ values: 'falsy' }).isString().isLength({ min: 5, max: 64 })
|
||||||
|
.bail().custom(ibanValidator({ required: false })),
|
||||||
|
body('label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('accountHolder').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('bic').optional({ values: 'falsy' }).isString().isLength({ max: 16 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('isDefault').optional({ values: 'falsy' }).isBoolean(),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequestWithFieldMessage(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const payload = {};
|
||||||
|
const map = {
|
||||||
|
iban: 'iban',
|
||||||
|
label: 'label',
|
||||||
|
accountHolder: 'account_holder',
|
||||||
|
bic: 'bic',
|
||||||
|
currency: 'currency',
|
||||||
|
isDefault: 'is_default',
|
||||||
|
displayOrder: 'display_order',
|
||||||
|
};
|
||||||
|
for (const [api, db] of Object.entries(map)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
|
||||||
|
payload[db] = req.body[api];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bank = await businessProfileService.updateBankAccount(id, payload, req.admin.id);
|
||||||
|
return successResponse(res, { bankAccount: transformBank(bank) }, 200, 'Bank account updated');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/bank-accounts/:id',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
await businessProfileService.deleteBankAccount(id, req.admin.id);
|
||||||
|
return successResponse(res, { deleted: true }, 200, 'Bank account deleted');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Admin calendar aggregate endpoint.
|
||||||
|
*
|
||||||
|
* One read returns four layers the frontend renders together on the
|
||||||
|
* admin calendar surface (`/admin/clients/calendar`):
|
||||||
|
*
|
||||||
|
* 1. events — galleries from the `events` table. Blue solid.
|
||||||
|
* 2. hours — customer_hour_entries. Green solid; greyed when
|
||||||
|
* locked (entry's invoice is past send/draft state).
|
||||||
|
* 3. quotes — quotes that haven't been converted to an event yet,
|
||||||
|
* `status IN ('sent','accepted')`. Amber dashed.
|
||||||
|
* 4. contracts — contracts that haven't been converted to an event
|
||||||
|
* yet, `status IN ('signed_by_customer','fully_signed')`.
|
||||||
|
* Purple dashed.
|
||||||
|
*
|
||||||
|
* Each item carries a `kind` discriminator so the frontend can union-type
|
||||||
|
* the response.
|
||||||
|
*
|
||||||
|
* **Access**
|
||||||
|
*
|
||||||
|
* Behind the `calendar` master feature flag (admin can disable globally
|
||||||
|
* via Settings → Features). Read permission is `customers.view` —
|
||||||
|
* mirrors the existing hour-entry list permission, since the calendar's
|
||||||
|
* primary mutation surface is hour entries and we want the same audience
|
||||||
|
* for read.
|
||||||
|
*
|
||||||
|
* **Range guard**
|
||||||
|
*
|
||||||
|
* `from` / `to` are required ISO date strings. We cap `to-from` at
|
||||||
|
* **90 days** so a misconfigured client (e.g. an infinite scroll that
|
||||||
|
* keeps expanding the range) can't trigger a multi-year scan. FullCalendar
|
||||||
|
* fetches month-by-month by default, so 90 days is a comfortable margin.
|
||||||
|
*
|
||||||
|
* **Drift guards**
|
||||||
|
*
|
||||||
|
* The `events.event_time_start / event_time_end / is_full_day` columns
|
||||||
|
* (migration 137) are read through `hasColumnCached` so un-migrated
|
||||||
|
* installs default to all-day rendering without 500-ing.
|
||||||
|
*
|
||||||
|
* **No mutations here**
|
||||||
|
*
|
||||||
|
* Hour-entry CRUD stays on the existing `/api/admin/customers/:id/
|
||||||
|
* hour-entries` routes (migration 129 + B.6 permission split). The
|
||||||
|
* calendar's drag-create / inline-edit modals call those directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { query } = require('express-validator');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const customerHoursService = require('../services/customerHoursService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ----- feature flag gate (admin global) ----------------------------------
|
||||||
|
async function requireCalendarFlag(req, res, next) {
|
||||||
|
try {
|
||||||
|
const row = await db('feature_flags').where({ key: 'calendar' }).first();
|
||||||
|
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
if (!enabled) {
|
||||||
|
return res.status(403).json({ error: 'Calendar feature is disabled', code: 'CALENDAR_DISABLED' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use(requireCalendarFlag);
|
||||||
|
|
||||||
|
const MAX_RANGE_DAYS = 90;
|
||||||
|
const PENDING_QUOTE_STATUSES = ['sent', 'accepted'];
|
||||||
|
const PENDING_CONTRACT_STATUSES = ['signed_by_customer', 'fully_signed'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a date column value to the `YYYY-MM-DD` string the
|
||||||
|
* frontend mapper expects.
|
||||||
|
*
|
||||||
|
* Different drivers return the value differently:
|
||||||
|
* - SQLite (dev) returns a string like "2026-05-18" — slice it.
|
||||||
|
* - node-postgres (prod) returns a JS Date set to UTC midnight of
|
||||||
|
* the stored day — extract the UTC components.
|
||||||
|
*
|
||||||
|
* The previous shape kept the Date object as-is in the JSON response
|
||||||
|
* ("2026-05-18T00:00:00.000Z"), which the frontend then concatenated
|
||||||
|
* with the entry time as `${dateStr}T${time}` to feed FullCalendar.
|
||||||
|
* The resulting `"2026-05-18T00:00:00.000ZT09:00"` was invalid ISO,
|
||||||
|
* FC parsed it to NaN, and the entry silently failed to render —
|
||||||
|
* making logged hours "disappear" on every hard refresh (entries
|
||||||
|
* created in-session still appeared because the imperative addEvent
|
||||||
|
* received a clean YYYY-MM-DD from the modal).
|
||||||
|
*/
|
||||||
|
function toIsoDateString(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
if (typeof value === 'string') return value.slice(0, 10);
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
const y = value.getUTCFullYear();
|
||||||
|
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(value.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/calendar/items?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
*
|
||||||
|
* Returns `{ items: [...], range: { from, to } }`.
|
||||||
|
*
|
||||||
|
* Items are concatenated across the four layers. Order is NOT guaranteed —
|
||||||
|
* FullCalendar sorts by start time client-side. Each item shape:
|
||||||
|
*
|
||||||
|
* - { kind: 'event', id, slug, eventName, eventDate, eventTimeStart, eventTimeEnd, isFullDay, customerName }
|
||||||
|
* - { kind: 'hours', id, customerAccountId, entryDate, startTime, endTime, description, locked, invoiceId, invoiceStatus, customerName }
|
||||||
|
* - { kind: 'quote', id, quoteNumber, eventName, eventDate, eventTimeStart, eventTimeEnd, status, customerName }
|
||||||
|
* - { kind: 'contract', id, contractNumber, eventName, eventDate, eventTimeStart, eventTimeEnd, status, customerName }
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/items',
|
||||||
|
requirePermission('customers.view'),
|
||||||
|
[
|
||||||
|
query('from').isISO8601().withMessage('from must be ISO date'),
|
||||||
|
query('to').isISO8601().withMessage('to must be ISO date'),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const from = String(req.query.from).slice(0, 10);
|
||||||
|
const to = String(req.query.to).slice(0, 10);
|
||||||
|
if (from > to) {
|
||||||
|
return res.status(400).json({ error: 'from must be <= to', code: 'INVALID_RANGE' });
|
||||||
|
}
|
||||||
|
// Day-span guard. Date-string lex compare doesn't give a day count
|
||||||
|
// directly; subtract via Date so DST + month boundaries are handled.
|
||||||
|
const fromDate = new Date(from + 'T00:00:00Z');
|
||||||
|
const toDate = new Date(to + 'T00:00:00Z');
|
||||||
|
const daysSpan = Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
|
||||||
|
if (daysSpan > MAX_RANGE_DAYS) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Range too wide (max ${MAX_RANGE_DAYS} days)`,
|
||||||
|
code: 'RANGE_TOO_WIDE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEventCalendarCols = await hasColumnCached('events', 'is_full_day');
|
||||||
|
|
||||||
|
// -- 1. Events ---------------------------------------------------------
|
||||||
|
const eventsQ = db('events')
|
||||||
|
.whereBetween('event_date', [from, to])
|
||||||
|
.where('is_active', true)
|
||||||
|
.where('is_archived', false)
|
||||||
|
.orderBy('event_date', 'asc');
|
||||||
|
// Project columns. The new time columns are guarded so older installs
|
||||||
|
// that ran the service before migration 137 still get sane defaults.
|
||||||
|
const eventsRows = await eventsQ.select(
|
||||||
|
'id', 'slug', 'event_name', 'event_date', 'customer_name',
|
||||||
|
...(hasEventCalendarCols
|
||||||
|
? ['event_time_start', 'event_time_end', 'is_full_day']
|
||||||
|
: []),
|
||||||
|
);
|
||||||
|
const events = eventsRows.map((r) => ({
|
||||||
|
kind: 'event',
|
||||||
|
id: r.id,
|
||||||
|
slug: r.slug,
|
||||||
|
eventName: r.event_name,
|
||||||
|
eventDate: toIsoDateString(r.event_date),
|
||||||
|
eventTimeStart: r.event_time_start || null,
|
||||||
|
eventTimeEnd: r.event_time_end || null,
|
||||||
|
isFullDay: hasEventCalendarCols
|
||||||
|
? (r.is_full_day === true || r.is_full_day === 1 || r.is_full_day === '1')
|
||||||
|
: true,
|
||||||
|
customerName: r.customer_name || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -- 2. Hour entries ---------------------------------------------------
|
||||||
|
// LEFT JOIN invoices so isEntryLocked has the invoice context it needs.
|
||||||
|
// We use the SAME predicate shape the service uses internally
|
||||||
|
// (customerHoursService._internal.isEntryLocked at lines 84-91) so
|
||||||
|
// the calendar's lock badge matches what the UI shows on the customer
|
||||||
|
// detail page.
|
||||||
|
const hoursRows = await db('customer_hour_entries as h')
|
||||||
|
.leftJoin('invoices as i', 'i.id', 'h.invoice_id')
|
||||||
|
.leftJoin('customer_accounts as c', 'c.id', 'h.customer_account_id')
|
||||||
|
.whereBetween('h.entry_date', [from, to])
|
||||||
|
.orderBy('h.entry_date', 'asc')
|
||||||
|
.select(
|
||||||
|
'h.id', 'h.customer_account_id', 'h.entry_date',
|
||||||
|
'h.start_time', 'h.end_time', 'h.description',
|
||||||
|
'h.invoice_id', 'h.invoice_line_item_id', 'h.status',
|
||||||
|
'i.status as invoice_status',
|
||||||
|
'i.is_monthly_draft as invoice_is_monthly_draft',
|
||||||
|
'i.scheduled_send_at as invoice_scheduled_send_at',
|
||||||
|
'c.display_name as customer_display_name',
|
||||||
|
'c.first_name as customer_first_name',
|
||||||
|
'c.last_name as customer_last_name',
|
||||||
|
'c.company_name as customer_company_name',
|
||||||
|
'c.email as customer_email',
|
||||||
|
);
|
||||||
|
const isEntryLocked = customerHoursService._internal.isEntryLocked;
|
||||||
|
const hours = hoursRows.map((r) => {
|
||||||
|
// Reconstruct the minimal entry + invoice shapes the locked
|
||||||
|
// predicate expects.
|
||||||
|
const entry = {
|
||||||
|
id: r.id,
|
||||||
|
invoice_id: r.invoice_id,
|
||||||
|
status: r.status,
|
||||||
|
};
|
||||||
|
const invoice = r.invoice_id ? {
|
||||||
|
id: r.invoice_id,
|
||||||
|
status: r.invoice_status,
|
||||||
|
is_monthly_draft: r.invoice_is_monthly_draft,
|
||||||
|
scheduled_send_at: r.invoice_scheduled_send_at,
|
||||||
|
} : null;
|
||||||
|
const locked = isEntryLocked(entry, invoice);
|
||||||
|
const customerName = r.customer_company_name
|
||||||
|
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|
||||||
|
|| r.customer_display_name
|
||||||
|
|| r.customer_email
|
||||||
|
|| null;
|
||||||
|
return {
|
||||||
|
kind: 'hours',
|
||||||
|
id: r.id,
|
||||||
|
customerAccountId: r.customer_account_id,
|
||||||
|
entryDate: toIsoDateString(r.entry_date),
|
||||||
|
startTime: r.start_time,
|
||||||
|
endTime: r.end_time,
|
||||||
|
description: r.description || null,
|
||||||
|
status: r.status,
|
||||||
|
invoiceId: r.invoice_id || null,
|
||||||
|
invoiceStatus: r.invoice_status || null,
|
||||||
|
locked,
|
||||||
|
customerName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- 3. Pending quotes ------------------------------------------------
|
||||||
|
// Only quotes with status IN ('sent','accepted') AND no converted
|
||||||
|
// event yet. The frontend renders these dashed amber.
|
||||||
|
const quotesRows = await db('quotes as q')
|
||||||
|
.leftJoin('customer_accounts as c', 'c.id', 'q.customer_account_id')
|
||||||
|
.whereIn('q.status', PENDING_QUOTE_STATUSES)
|
||||||
|
.whereNull('q.converted_event_id')
|
||||||
|
.whereNotNull('q.event_date')
|
||||||
|
.whereBetween('q.event_date', [from, to])
|
||||||
|
.orderBy('q.event_date', 'asc')
|
||||||
|
.select(
|
||||||
|
'q.id', 'q.quote_number', 'q.event_name', 'q.event_date',
|
||||||
|
'q.event_time_start', 'q.event_time_end', 'q.status',
|
||||||
|
'c.display_name as customer_display_name',
|
||||||
|
'c.first_name as customer_first_name',
|
||||||
|
'c.last_name as customer_last_name',
|
||||||
|
'c.company_name as customer_company_name',
|
||||||
|
'c.email as customer_email',
|
||||||
|
);
|
||||||
|
const quotes = quotesRows.map((r) => ({
|
||||||
|
kind: 'quote',
|
||||||
|
id: r.id,
|
||||||
|
quoteNumber: r.quote_number,
|
||||||
|
eventName: r.event_name || null,
|
||||||
|
eventDate: toIsoDateString(r.event_date),
|
||||||
|
eventTimeStart: r.event_time_start || null,
|
||||||
|
eventTimeEnd: r.event_time_end || null,
|
||||||
|
status: r.status,
|
||||||
|
customerName: r.customer_company_name
|
||||||
|
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|
||||||
|
|| r.customer_display_name
|
||||||
|
|| r.customer_email
|
||||||
|
|| null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -- 4. Pending contracts --------------------------------------------
|
||||||
|
const contractsRows = await db('contracts as c')
|
||||||
|
.leftJoin('customer_accounts as ca', 'ca.id', 'c.customer_account_id')
|
||||||
|
.whereIn('c.status', PENDING_CONTRACT_STATUSES)
|
||||||
|
.whereNull('c.converted_event_id')
|
||||||
|
.whereNotNull('c.event_date')
|
||||||
|
.whereBetween('c.event_date', [from, to])
|
||||||
|
.orderBy('c.event_date', 'asc')
|
||||||
|
.select(
|
||||||
|
'c.id', 'c.contract_number', 'c.event_name', 'c.event_date',
|
||||||
|
'c.event_time_start', 'c.event_time_end', 'c.status',
|
||||||
|
'ca.display_name as customer_display_name',
|
||||||
|
'ca.first_name as customer_first_name',
|
||||||
|
'ca.last_name as customer_last_name',
|
||||||
|
'ca.company_name as customer_company_name',
|
||||||
|
'ca.email as customer_email',
|
||||||
|
);
|
||||||
|
const contracts = contractsRows.map((r) => ({
|
||||||
|
kind: 'contract',
|
||||||
|
id: r.id,
|
||||||
|
contractNumber: r.contract_number,
|
||||||
|
eventName: r.event_name || null,
|
||||||
|
eventDate: toIsoDateString(r.event_date),
|
||||||
|
eventTimeStart: r.event_time_start || null,
|
||||||
|
eventTimeEnd: r.event_time_end || null,
|
||||||
|
status: r.status,
|
||||||
|
customerName: r.customer_company_name
|
||||||
|
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|
||||||
|
|| r.customer_display_name
|
||||||
|
|| r.customer_email
|
||||||
|
|| null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = [...events, ...hours, ...quotes, ...contracts];
|
||||||
|
return successResponse(res, { items, range: { from, to } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Contracts Routes
|
||||||
|
*
|
||||||
|
* Endpoint mounted at /api/admin/contracts. Surface:
|
||||||
|
* GET / list (filter + sort + paginate)
|
||||||
|
* POST / create (status=draft, seeded with all active system blocks)
|
||||||
|
* GET /:id detail (contract + included blocks)
|
||||||
|
* PUT /:id update (block toggles + scalars; draft only)
|
||||||
|
* POST /:id/send render PDF + mint token + queue email
|
||||||
|
* POST /:id/cancel cancel (draft|sent)
|
||||||
|
* POST /:id/countersign admin in-browser counter-signature
|
||||||
|
* POST /:id/upload-signed-pdf attach wet-signed PDF (multer single)
|
||||||
|
* GET /:id/pdf download / preview the system PDF
|
||||||
|
* GET /:id/signed-pdf download the wet-signed PDF (when present)
|
||||||
|
* GET /:id/preview render fresh PDF for preview (no DB write)
|
||||||
|
* GET /blocks list block library
|
||||||
|
* POST /blocks create admin-authored block
|
||||||
|
* PUT /blocks/:id update a block (system blocks: body remains editable)
|
||||||
|
* DELETE /blocks/:id delete an admin-authored block (system blocks refuse)
|
||||||
|
*
|
||||||
|
* Permissions: `contracts.view` for reads, `contracts.manage` for writes.
|
||||||
|
* The global `contracts` feature flag is checked at the route layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { assertContractPdfPath } = require('../utils/safePath');
|
||||||
|
const { body, param, query } = require('express-validator');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||||
|
const contractService = require('../services/contractService');
|
||||||
|
const contractBlocksService = require('../services/contractBlocksService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ----- feature flag gate (admin global) -------------------------------
|
||||||
|
async function requireContractsFlag(req, res, next) {
|
||||||
|
try {
|
||||||
|
const row = await db('feature_flags').where({ key: 'contracts' }).first();
|
||||||
|
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
if (!enabled) {
|
||||||
|
return res.status(403).json({ error: 'Contracts feature is disabled', code: 'CONTRACTS_DISABLED' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use(requireContractsFlag);
|
||||||
|
|
||||||
|
// ----- multer upload (wet-signed PDF) --------------------------------
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
const signedPdfStorage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(getStoragePath(), 'uploads/contracts/signed');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname) || '.pdf';
|
||||||
|
cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedPdfUpload = multer({
|
||||||
|
storage: signedPdfStorage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = ['application/pdf'];
|
||||||
|
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||||
|
return cb(new Error('Only PDF files are allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Transforms (snake_case DB → camelCase API)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function transformContract(c, inclusions) {
|
||||||
|
if (!c) return null;
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
contractNumber: c.contract_number,
|
||||||
|
customerAccountId: c.customer_account_id,
|
||||||
|
customer: {
|
||||||
|
email: c.customer_email,
|
||||||
|
displayName: c.customer_display_name,
|
||||||
|
firstName: c.customer_first_name,
|
||||||
|
lastName: c.customer_last_name,
|
||||||
|
companyName: c.customer_company_name,
|
||||||
|
preferredLanguage: c.customer_preferred_language,
|
||||||
|
},
|
||||||
|
status: c.status,
|
||||||
|
// Migration 140 — cross-document lineage UUID. See adminQuotes
|
||||||
|
// transform for the same note; lets the lineage card fetch every
|
||||||
|
// related doc in one query.
|
||||||
|
dealUuid: c.deal_uuid || null,
|
||||||
|
language: c.language,
|
||||||
|
issueDate: c.issue_date,
|
||||||
|
validUntil: c.valid_until,
|
||||||
|
title: c.title,
|
||||||
|
// Event snapshot fields (migration 130 in-place edit). Null when
|
||||||
|
// the standalone contract didn't set them OR when the column
|
||||||
|
// hasn't migrated yet on this install — the API surface stays
|
||||||
|
// stable either way.
|
||||||
|
eventName: c.event_name || null,
|
||||||
|
eventDate: c.event_date || null,
|
||||||
|
eventTimeStart: c.event_time_start || null,
|
||||||
|
eventTimeEnd: c.event_time_end || null,
|
||||||
|
introText: c.intro_text,
|
||||||
|
outroText: c.outro_text,
|
||||||
|
pdfPath: c.pdf_path,
|
||||||
|
signedPdfPath: c.signed_pdf_path,
|
||||||
|
// Audit defence: SHA-256 hashes of the on-disk PDFs computed at
|
||||||
|
// each write. Either party can re-hash the PDF they hold and
|
||||||
|
// compare against these to prove the file hasn't been tampered
|
||||||
|
// with since we issued it.
|
||||||
|
pdfSha256: c.pdf_sha256 || null,
|
||||||
|
signedPdfSha256: c.signed_pdf_sha256 || null,
|
||||||
|
// Migration 136 — surface the post-sign re-stamp failure marker so
|
||||||
|
// the admin detail page can render a recovery banner. Null when
|
||||||
|
// the most recent stamp succeeded (or the migration hasn't run on
|
||||||
|
// this install — the front-end branches on truthiness).
|
||||||
|
signedPdfRenderFailedAt: c.signed_pdf_render_failed_at || null,
|
||||||
|
signedPdfRenderError: c.signed_pdf_render_error || null,
|
||||||
|
sentAt: c.sent_at,
|
||||||
|
signedByCustomerAt: c.signed_by_customer_at,
|
||||||
|
signedByAdminAt: c.signed_by_admin_at,
|
||||||
|
signedCustomerName: c.signed_customer_name,
|
||||||
|
signedCustomerIp: c.signed_customer_ip,
|
||||||
|
signedCustomerSignaturePath: c.signed_customer_signature_path,
|
||||||
|
signedAdminName: c.signed_admin_name,
|
||||||
|
signedAdminIp: c.signed_admin_ip,
|
||||||
|
signedAdminSignaturePath: c.signed_admin_signature_path,
|
||||||
|
createdByAdminId: c.created_by_admin_id,
|
||||||
|
// Lineage back-pointers (migration 130). Surfaced so the
|
||||||
|
// ContractDetailPage can render "Linked quote" + "Linked
|
||||||
|
// invoices" panels alongside the existing block list. The
|
||||||
|
// values are nullable when the back-pointers haven't been
|
||||||
|
// populated (e.g. dev DB without the column migration; the
|
||||||
|
// service writes them through hasColumn guards).
|
||||||
|
sourceQuoteId: c.source_quote_id || null,
|
||||||
|
convertedEventId: c.converted_event_id || null,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
inclusions: Array.isArray(inclusions)
|
||||||
|
? inclusions.map((inc) => ({
|
||||||
|
id: inc.id,
|
||||||
|
blockId: inc.block_id,
|
||||||
|
section: inc.section,
|
||||||
|
position: inc.position,
|
||||||
|
included: inc.included === true || inc.included === 1 || inc.included === '1',
|
||||||
|
block: {
|
||||||
|
slug: inc.block_slug,
|
||||||
|
name: inc.block_name,
|
||||||
|
description: inc.block_description,
|
||||||
|
bodyText: inc.block_body_text,
|
||||||
|
bodyTextDe: inc.block_body_text_de,
|
||||||
|
isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1',
|
||||||
|
},
|
||||||
|
bodyTextSnapshot: inc.body_text_snapshot,
|
||||||
|
bodyTextDeSnapshot: inc.body_text_de_snapshot,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBlock(b) {
|
||||||
|
if (!b) return null;
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
slug: b.slug,
|
||||||
|
section: b.section,
|
||||||
|
name: b.name,
|
||||||
|
description: b.description,
|
||||||
|
bodyText: b.body_text,
|
||||||
|
bodyTextDe: b.body_text_de,
|
||||||
|
// Migration 131 — additional language bodies. Fall back to null
|
||||||
|
// on schema-drift (column missing on a not-yet-migrated install)
|
||||||
|
// so the field always exists on the JSON shape.
|
||||||
|
bodyTextRu: b.body_text_ru ?? null,
|
||||||
|
bodyTextPt: b.body_text_pt ?? null,
|
||||||
|
bodyTextNl: b.body_text_nl ?? null,
|
||||||
|
bodyTextFr: b.body_text_fr ?? null,
|
||||||
|
isSystem: b.is_system === true || b.is_system === 1 || b.is_system === '1',
|
||||||
|
isActive: b.is_active === true || b.is_active === 1 || b.is_active === '1',
|
||||||
|
displayOrder: b.display_order,
|
||||||
|
createdAt: b.created_at,
|
||||||
|
updatedAt: b.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Block library — placed BEFORE /:id routes so 'blocks' isn't captured
|
||||||
|
// as an id (express-validator wouldn't matter, but Express order would).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/blocks',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[query('section').optional().isString(), query('includeInactive').optional().isBoolean()],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const blocks = await contractBlocksService.listBlocks({
|
||||||
|
section: req.query.section,
|
||||||
|
includeInactive: req.query.includeInactive === 'true' || req.query.includeInactive === true,
|
||||||
|
});
|
||||||
|
return successResponse(res, { blocks: blocks.map(transformBlock) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/blocks',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
body('section').isString().isIn(contractBlocksService.ALLOWED_SECTIONS),
|
||||||
|
body('name').isString().isLength({ min: 1, max: 128 }),
|
||||||
|
body('bodyText').isString().isLength({ min: 1 }),
|
||||||
|
body('bodyTextDe').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextRu').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextPt').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextNl').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextFr').optional({ nullable: true }).isString(),
|
||||||
|
body('description').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('displayOrder').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
|
body('isActive').optional().isBoolean(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const block = await contractBlocksService.createBlock(req.body);
|
||||||
|
return successResponse(res, { block: transformBlock(block) }, 201);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/blocks/:id',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('section').optional().isString().isIn(contractBlocksService.ALLOWED_SECTIONS),
|
||||||
|
body('name').optional().isString().isLength({ min: 1, max: 128 }),
|
||||||
|
body('bodyText').optional().isString().isLength({ min: 1 }),
|
||||||
|
body('bodyTextDe').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextRu').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextPt').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextNl').optional({ nullable: true }).isString(),
|
||||||
|
body('bodyTextFr').optional({ nullable: true }).isString(),
|
||||||
|
body('description').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('displayOrder').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
|
body('isActive').optional().isBoolean(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const block = await contractBlocksService.updateBlock(parseInt(req.params.id, 10), req.body);
|
||||||
|
return successResponse(res, { block: transformBlock(block) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/blocks/:id',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await contractBlocksService.deleteBlock(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Contracts
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[
|
||||||
|
query('status').optional().isString(),
|
||||||
|
query('customerAccountId').optional().isInt({ min: 1 }),
|
||||||
|
query('q').optional().isString(),
|
||||||
|
query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']),
|
||||||
|
query('page').optional().isInt({ min: 1 }),
|
||||||
|
query('pageSize').optional().isInt({ min: 1, max: 200 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const filters = {};
|
||||||
|
if (req.query.status) {
|
||||||
|
filters.status = String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (req.query.customerAccountId) filters.customerAccountId = parseInt(req.query.customerAccountId, 10);
|
||||||
|
if (req.query.q) filters.q = String(req.query.q);
|
||||||
|
const page = parseInt(req.query.page, 10) || 1;
|
||||||
|
const pageSize = parseInt(req.query.pageSize, 10) || 25;
|
||||||
|
const result = await contractService.listContracts({
|
||||||
|
filters,
|
||||||
|
sort: req.query.sort || 'newest',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
return successResponse(res, {
|
||||||
|
contracts: result.rows.map((row) => transformContract(row)),
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
body('customerAccountId').isInt({ min: 1 }),
|
||||||
|
body('language').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('title').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventName').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventDate').optional({ nullable: true }).isISO8601(),
|
||||||
|
body('eventTimeStart').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('eventTimeEnd').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('introText').optional({ nullable: true }).isString(),
|
||||||
|
body('outroText').optional({ nullable: true }).isString(),
|
||||||
|
body('issueDate').optional({ nullable: true }).isISO8601(),
|
||||||
|
body('validUntil').optional({ nullable: true }).isISO8601(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = await contractService.createContract(req.body, req.admin?.id);
|
||||||
|
const data = await contractService.getContractById(id);
|
||||||
|
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) }, 201);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const data = await contractService.getContractById(parseInt(req.params.id, 10));
|
||||||
|
if (!data) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('title').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventName').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventDate').optional({ nullable: true }).isISO8601(),
|
||||||
|
body('eventTimeStart').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('eventTimeEnd').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('introText').optional({ nullable: true }).isString(),
|
||||||
|
body('outroText').optional({ nullable: true }).isString(),
|
||||||
|
body('language').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
|
body('issueDate').optional({ nullable: true }).isISO8601(),
|
||||||
|
body('validUntil').optional({ nullable: true }).isISO8601(),
|
||||||
|
body('blocks').optional().isArray(),
|
||||||
|
body('blocks.*.blockId').optional().isInt({ min: 1 }),
|
||||||
|
body('blocks.*.included').optional().isBoolean(),
|
||||||
|
body('blocks.*.position').optional().isInt({ min: 0 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await contractService.updateContract(parseInt(req.params.id, 10), req.body, req.admin?.id);
|
||||||
|
const data = await contractService.getContractById(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/send',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.sendContract(parseInt(req.params.id, 10), req.admin?.id);
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/cancel',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.cancelContract(parseInt(req.params.id, 10), req.admin?.id);
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert a fully-signed contract into an event + scheduled invoices.
|
||||||
|
// Delegates to quoteService via the contract's source_quote_id; refuses
|
||||||
|
// when source_quote_id is null (standalone contracts have no line items
|
||||||
|
// to replay).
|
||||||
|
router.post(
|
||||||
|
'/:id/convert-to-event',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.convertToEvent(parseInt(req.params.id, 10), req.admin?.id);
|
||||||
|
return successResponse(res, result, 200,
|
||||||
|
result.alreadyConverted ? 'Already converted to event' : 'Contract converted to event');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert a fully-signed contract into invoice(s) only — no event.
|
||||||
|
router.post(
|
||||||
|
'/:id/convert-to-invoice',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.convertToInvoiceOnly(parseInt(req.params.id, 10), req.admin?.id);
|
||||||
|
return successResponse(res, result, 200, 'Invoices created from contract');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render the signed PDF (when it's a system render, not a wet-
|
||||||
|
// signed upload) and resend the contract_fully_signed email to both
|
||||||
|
// parties. Recovery action for contracts where the initial dual-party
|
||||||
|
// send failed silently, or where the customer claims they didn't
|
||||||
|
// receive the email.
|
||||||
|
router.post(
|
||||||
|
'/:id/resend-signed',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.rerenderAndResend(parseInt(req.params.id, 10), req.admin?.id);
|
||||||
|
return successResponse(res, result, 200, 'Signed contract re-sent to both parties');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-stamp one or both signature images on a contract whose original
|
||||||
|
// sign happened before the canvas worked correctly. The admin draws
|
||||||
|
// the missing signature(s) on the detail page; this endpoint persists
|
||||||
|
// the PNGs, updates signature_path columns, and re-renders the PDF.
|
||||||
|
// The customer's typed name + timestamp + IP stay untouched — only
|
||||||
|
// the image bound to those evidence fields gets refreshed.
|
||||||
|
router.post(
|
||||||
|
'/:id/restamp-signatures',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('customerSignatureDataUrl').optional({ nullable: true }).isString(),
|
||||||
|
body('adminSignatureDataUrl').optional({ nullable: true }).isString(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.restampSignatures(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
{
|
||||||
|
customerSignatureDataUrl: req.body.customerSignatureDataUrl || null,
|
||||||
|
adminSignatureDataUrl: req.body.adminSignatureDataUrl || null,
|
||||||
|
},
|
||||||
|
req.admin?.id,
|
||||||
|
);
|
||||||
|
return successResponse(res, result, 200, 'Signatures re-stamped and PDF re-rendered');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/countersign',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('name').isString().isLength({ min: 1, max: 255 }),
|
||||||
|
body('signatureDataUrl').optional({ nullable: true }).isString(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const ip = req.ip || req.headers['x-forwarded-for'] || null;
|
||||||
|
const result = await contractService.recordAdminCountersignature(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
{ name: req.body.name, ip, signatureDataUrl: req.body.signatureDataUrl },
|
||||||
|
req.admin?.id,
|
||||||
|
);
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/upload-signed-pdf',
|
||||||
|
requirePermission('contracts.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
signedPdfUpload.single('file'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
|
||||||
|
}
|
||||||
|
const result = await contractService.attachSignedPdfUpload(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
req.file.path,
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/pdf',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const data = await contractService.getContractById(parseInt(req.params.id, 10));
|
||||||
|
if (!data) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
if (!data.contract.pdf_path) {
|
||||||
|
return res.status(404).json({ error: 'PDF not yet rendered', code: 'PDF_MISSING' });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(data.contract.pdf_path)) {
|
||||||
|
return res.status(404).json({ error: 'PDF file missing from disk', code: 'PDF_MISSING_ON_DISK' });
|
||||||
|
}
|
||||||
|
// Defence-in-depth: reject any path that resolves outside the
|
||||||
|
// contract storage roots before we open the stream. Today the DB
|
||||||
|
// paths are always written by the service layer, but a future
|
||||||
|
// migration bug or hand-edited row should not turn this endpoint
|
||||||
|
// into an arbitrary-file-read primitive.
|
||||||
|
const safePath = assertContractPdfPath(data.contract.pdf_path);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${data.contract.contract_number}.pdf"`,
|
||||||
|
);
|
||||||
|
fs.createReadStream(safePath).pipe(res);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/signed-pdf',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const data = await contractService.getContractById(parseInt(req.params.id, 10));
|
||||||
|
if (!data) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
if (!data.contract.signed_pdf_path) {
|
||||||
|
return res.status(404).json({ error: 'No signed PDF uploaded', code: 'SIGNED_PDF_MISSING' });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(data.contract.signed_pdf_path)) {
|
||||||
|
return res.status(404).json({ error: 'Signed PDF missing from disk', code: 'SIGNED_PDF_MISSING_ON_DISK' });
|
||||||
|
}
|
||||||
|
const safePath = assertContractPdfPath(data.contract.signed_pdf_path);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${data.contract.contract_number}-signed.pdf"`,
|
||||||
|
);
|
||||||
|
fs.createReadStream(safePath).pipe(res);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audit trail — chronological activity_logs entries for this contract.
|
||||||
|
// Used by the AuditTrailCard on the admin detail page; read-only.
|
||||||
|
router.get(
|
||||||
|
'/:id/audit-trail',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const entries = await contractService.getAuditTrail(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { entries });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Integrity check — re-hashes pdf_path + signed_pdf_path on disk and
|
||||||
|
// compares to the stored pdf_sha256 / signed_pdf_sha256 (migration
|
||||||
|
// 131). Lets the admin confirm a contract PDF on disk still matches
|
||||||
|
// what was issued, catching backup-corruption / manual-edit cases
|
||||||
|
// without needing to drop to a shell.
|
||||||
|
router.get(
|
||||||
|
'/:id/verify-integrity',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await contractService.verifyIntegrity(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/preview',
|
||||||
|
requirePermission('contracts.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const buffer = await contractService.renderContractPdfBuffer(parseInt(req.params.id, 10));
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'inline; filename="contract-preview.pdf"');
|
||||||
|
return res.send(buffer);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -12,6 +12,8 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
const customerAccountsService = require('../services/customerAccountsService');
|
const customerAccountsService = require('../services/customerAccountsService');
|
||||||
|
const customerHoursService = require('../services/customerHoursService');
|
||||||
|
const invoiceService = require('../services/invoiceService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -38,14 +40,32 @@ function transformCustomer(c) {
|
|||||||
city: c.city,
|
city: c.city,
|
||||||
state: c.state,
|
state: c.state,
|
||||||
countryCode: c.country_code,
|
countryCode: c.country_code,
|
||||||
|
countryName: c.country_name,
|
||||||
preferredLanguage: c.preferred_language,
|
preferredLanguage: c.preferred_language,
|
||||||
|
// CRM billing cadence override (migration 102). Drives whether the
|
||||||
|
// invoice scheduler honours the quote's installment plan or snaps
|
||||||
|
// every bill to the customer's monthly/quarterly cycle day.
|
||||||
|
billingCadence: c.billing_cadence || 'per_event',
|
||||||
|
billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day),
|
||||||
notes: c.notes,
|
notes: c.notes,
|
||||||
isActive: c.is_active,
|
isActive: c.is_active,
|
||||||
|
// Passive customers (admin-only, no portal access) are identified
|
||||||
|
// by a null password_hash. We never expose the hash itself —
|
||||||
|
// this boolean is the only thing the frontend ever sees, and it
|
||||||
|
// drives the "Passive — admin only" badge + the "Send portal
|
||||||
|
// invitation" button on the detail page.
|
||||||
|
isPassive: c.password_hash == null,
|
||||||
// Per-customer feature flags (#354 follow-up). Coerce to bool so the
|
// Per-customer feature flags (#354 follow-up). Coerce to bool so the
|
||||||
// frontend doesn't have to deal with SQLite's 0/1 values.
|
// frontend doesn't have to deal with SQLite's 0/1 values.
|
||||||
featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
|
featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
|
||||||
featureQuotes: c.feature_quotes === true || c.feature_quotes === 1,
|
featureQuotes: c.feature_quotes === true || c.feature_quotes === 1,
|
||||||
featureBills: c.feature_bills === true || c.feature_bills === 1,
|
featureBills: c.feature_bills === true || c.feature_bills === 1,
|
||||||
|
// Hours logging (migration 129) — fourth per-customer flag.
|
||||||
|
// Default hourly rate (in minor units) is null when admin hasn't
|
||||||
|
// set one; the editor surfaces it as an empty input and forces a
|
||||||
|
// per-entry override on every logged block.
|
||||||
|
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
|
||||||
|
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
|
||||||
lastLogin: c.last_login,
|
lastLogin: c.last_login,
|
||||||
createdAt: c.created_at,
|
createdAt: c.created_at,
|
||||||
updatedAt: c.updated_at,
|
updatedAt: c.updated_at,
|
||||||
@@ -141,6 +161,11 @@ router.post('/invite', [
|
|||||||
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
||||||
|
// Per-customer preferred language. Drives portal UI + quote/invoice
|
||||||
|
// PDF locale. Defaults at insert time to the business profile's
|
||||||
|
// default_locale when the admin doesn't supply one (see
|
||||||
|
// customerAccountsService.acceptInvitation).
|
||||||
|
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
|
||||||
], handleAsync(async (req, res) => {
|
], handleAsync(async (req, res) => {
|
||||||
validateRequest(req);
|
validateRequest(req);
|
||||||
const invitation = await customerAccountsService.createInvitation({
|
const invitation = await customerAccountsService.createInvitation({
|
||||||
@@ -162,7 +187,15 @@ router.post('/invite', [
|
|||||||
expiresAt: invitation.expiresAt,
|
expiresAt: invitation.expiresAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
// C.7 — hardened token echo. The previous shape gated on
|
||||||
|
// `NODE_ENV !== 'production'`, which is true in dev AND when the
|
||||||
|
// variable is unset entirely (some hosting setups never set
|
||||||
|
// NODE_ENV in their entrypoint). That meant the raw invitation
|
||||||
|
// token could leak in production-shaped deployments where the env
|
||||||
|
// happened to be unset. Now requires an EXPLICIT opt-in
|
||||||
|
// (`PICPEAK_ECHO_INVITE_TOKEN=1`) so a misconfigured production
|
||||||
|
// host fails closed instead of open.
|
||||||
|
if (process.env.PICPEAK_ECHO_INVITE_TOKEN === '1') {
|
||||||
payload.invitation.token = invitation.token;
|
payload.invitation.token = invitation.token;
|
||||||
}
|
}
|
||||||
successResponse(res, payload, 201);
|
successResponse(res, payload, 201);
|
||||||
@@ -181,6 +214,114 @@ router.delete('/invitations/:id', [
|
|||||||
successResponse(res, { message: 'Invitation cancelled' });
|
successResponse(res, { message: 'Invitation cancelled' });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ---- create passive customer (no invitation, admin-only) ----------------
|
||||||
|
//
|
||||||
|
// Counterpart to POST /invite: instead of creating an invitation row +
|
||||||
|
// email, this endpoint inserts the customer directly with
|
||||||
|
// password_hash=null (passive). The admin uses this when they have all
|
||||||
|
// the customer's info on hand and just need an identity to attach a
|
||||||
|
// quote / invoice / gallery to — no portal access required.
|
||||||
|
//
|
||||||
|
// Same per-field validators as /invite's prefill block, plus `email`
|
||||||
|
// required at the top level. Permission: customers.create.
|
||||||
|
router.post('/', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.create'),
|
||||||
|
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||||
|
body('prefill').optional().isObject(),
|
||||||
|
body('prefill.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
|
||||||
|
body('prefill.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
|
||||||
|
body('prefill.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
|
||||||
|
body('prefill.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('prefill.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
|
||||||
|
body('prefill.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('prefill.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
|
||||||
|
body('prefill.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('prefill.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
|
body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
|
||||||
|
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
||||||
|
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const { id } = await customerAccountsService.createDirect({
|
||||||
|
email: req.body.email,
|
||||||
|
prefill: req.body.prefill,
|
||||||
|
createdByAdminId: req.admin.id,
|
||||||
|
});
|
||||||
|
const customer = await customerAccountsService.getCustomerById(id);
|
||||||
|
successResponse(res, { customer: transformCustomer(customer) }, 201);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- promote a passive customer to active (send portal invitation) ------
|
||||||
|
//
|
||||||
|
// Fires the standard customer-invitation email flow at a customer who
|
||||||
|
// currently has no password_hash. The customer clicks the link, lands
|
||||||
|
// on the accept page (pre-populated with their existing profile),
|
||||||
|
// chooses a password, and is now active. The customer's id stays the
|
||||||
|
// same — all their invoices/quotes/gallery assignments survive.
|
||||||
|
//
|
||||||
|
// 409 with code CUSTOMER_ALREADY_ACTIVE when the customer already has
|
||||||
|
// a password set, so the button on the detail page can render an
|
||||||
|
// appropriate error toast.
|
||||||
|
router.post('/:id/send-invite', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.create'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const customerId = parseInt(req.params.id, 10);
|
||||||
|
const customer = await customerAccountsService.getCustomerById(customerId);
|
||||||
|
if (customer.password_hash) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Customer already has portal access — no invitation needed.',
|
||||||
|
code: 'CUSTOMER_ALREADY_ACTIVE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Derive the invitation prefill from the customer's existing
|
||||||
|
// profile so the accept page is pre-populated with what the admin
|
||||||
|
// already entered for them (saves the customer typing it again).
|
||||||
|
// Only the whitelisted fields go through.
|
||||||
|
const prefill = {
|
||||||
|
salutation: customer.salutation,
|
||||||
|
first_name: customer.first_name,
|
||||||
|
last_name: customer.last_name,
|
||||||
|
display_name: customer.display_name,
|
||||||
|
phone: customer.phone,
|
||||||
|
company_name: customer.company_name,
|
||||||
|
vat_id: customer.vat_id,
|
||||||
|
address_line1: customer.address_line1,
|
||||||
|
address_line2: customer.address_line2,
|
||||||
|
postal_code: customer.postal_code,
|
||||||
|
city: customer.city,
|
||||||
|
state: customer.state,
|
||||||
|
country_code: customer.country_code,
|
||||||
|
country_name: customer.country_name,
|
||||||
|
preferred_language: customer.preferred_language,
|
||||||
|
};
|
||||||
|
const invitation = await customerAccountsService.createInvitation({
|
||||||
|
email: customer.email,
|
||||||
|
invitedById: req.admin.id,
|
||||||
|
prefill,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
invitation: {
|
||||||
|
id: invitation.id,
|
||||||
|
email: invitation.email,
|
||||||
|
expiresAt: invitation.expiresAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// C.7 — see the matching gate on POST /invite. Explicit opt-in
|
||||||
|
// (`PICPEAK_ECHO_INVITE_TOKEN=1`) fails closed when NODE_ENV is
|
||||||
|
// unset in a production-shaped deployment.
|
||||||
|
if (process.env.PICPEAK_ECHO_INVITE_TOKEN === '1') {
|
||||||
|
payload.invitation.token = invitation.token;
|
||||||
|
}
|
||||||
|
successResponse(res, payload, 201);
|
||||||
|
}));
|
||||||
|
|
||||||
// ---- customer record ----------------------------------------------------
|
// ---- customer record ----------------------------------------------------
|
||||||
|
|
||||||
router.get('/:id', [
|
router.get('/:id', [
|
||||||
@@ -197,15 +338,24 @@ router.get('/:id', [
|
|||||||
|
|
||||||
router.put('/:id', [
|
router.put('/:id', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
requirePermission('customers.create'),
|
// Migration 134 — record-edit scope split out of customers.create.
|
||||||
|
// Roles that previously held customers.create were granted
|
||||||
|
// customers.edit on upgrade so behavior is preserved.
|
||||||
|
requirePermission('customers.edit'),
|
||||||
param('id').isInt({ min: 1 }),
|
param('id').isInt({ min: 1 }),
|
||||||
body('email').optional().isEmail().normalizeEmail(),
|
body('email').optional().isEmail().normalizeEmail(),
|
||||||
body('salutation').optional().isString().isLength({ max: 32 }),
|
// `{ nullable: true }` so a passive customer who has no salutation /
|
||||||
body('first_name').optional().isString().isLength({ max: 80 }),
|
// phone / company in their record can still save the page — the
|
||||||
body('last_name').optional().isString().isLength({ max: 80 }),
|
// form sends `null` for those empty fields, and plain `.optional()`
|
||||||
body('display_name').optional().isString().isLength({ max: 120 }),
|
// (which only skips `undefined`) would reject null at the
|
||||||
body('phone').optional().isString().isLength({ max: 40 }),
|
// subsequent `.isString()` step. Mirrors the existing pattern on
|
||||||
body('company_name').optional().isString().isLength({ max: 120 }),
|
// billing_email / vat_id / address_* below.
|
||||||
|
body('salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
|
||||||
|
body('first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
|
||||||
|
body('last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
|
||||||
|
body('display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
|
||||||
|
body('company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('billing_email').optional({ nullable: true }).isString(),
|
body('billing_email').optional({ nullable: true }).isString(),
|
||||||
body('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
|
body('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
|
||||||
body('address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
body('address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||||
@@ -214,12 +364,24 @@ router.put('/:id', [
|
|||||||
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
||||||
body('preferred_language').optional().isString().isLength({ max: 8 }),
|
body('country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
|
body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }),
|
||||||
body('notes').optional({ nullable: true }).isString(),
|
body('notes').optional({ nullable: true }).isString(),
|
||||||
body('is_active').optional().isBoolean(),
|
body('is_active').optional().isBoolean(),
|
||||||
body('feature_calendar').optional().isBoolean(),
|
body('feature_calendar').optional().isBoolean(),
|
||||||
body('feature_quotes').optional().isBoolean(),
|
body('feature_quotes').optional().isBoolean(),
|
||||||
body('feature_bills').optional().isBoolean(),
|
body('feature_bills').optional().isBoolean(),
|
||||||
|
// Hours logging (migration 129).
|
||||||
|
body('feature_hours_logging').optional().isBoolean(),
|
||||||
|
body('hourly_rate_minor').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
|
// CRM billing cadence — see migration 102. `per_event` keeps the
|
||||||
|
// existing per-event payment plan; monthly/quarterly snap every
|
||||||
|
// generated invoice to billing_cycle_day of the next period.
|
||||||
|
// Cycle day spans -15..-1 (days before month end) and 1..28
|
||||||
|
// (day of month) per migration 128 + service-layer clamp.
|
||||||
|
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
|
||||||
|
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
|
||||||
|
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
|
||||||
], handleAsync(async (req, res) => {
|
], handleAsync(async (req, res) => {
|
||||||
validateRequest(req);
|
validateRequest(req);
|
||||||
const customer = await customerAccountsService.updateCustomer(
|
const customer = await customerAccountsService.updateCustomer(
|
||||||
@@ -327,7 +489,11 @@ router.post('/:id/password-reset', [
|
|||||||
*/
|
*/
|
||||||
router.put('/:id/events', [
|
router.put('/:id/events', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
requirePermission('customers.create'),
|
// Migration 134 — event-assignment scope split out of customers.create.
|
||||||
|
// Lets an admin grant a coordinator the ability to re-target a customer
|
||||||
|
// between weddings without also unlocking VAT-ID / billing-address
|
||||||
|
// edits on every customer they can see.
|
||||||
|
requirePermission('customers.events'),
|
||||||
param('id').isInt({ min: 1 }),
|
param('id').isInt({ min: 1 }),
|
||||||
body('event_ids').isArray(),
|
body('event_ids').isArray(),
|
||||||
body('event_ids.*').isInt({ min: 1 }),
|
body('event_ids.*').isInt({ min: 1 }),
|
||||||
@@ -341,4 +507,163 @@ router.put('/:id/events', [
|
|||||||
successResponse(res, result);
|
successResponse(res, result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Hour entries (migration 129).
|
||||||
|
//
|
||||||
|
// Five endpoints under /api/admin/customers/:id/hour-entries — list,
|
||||||
|
// create, update, delete, plus the per-event "Bill these hours"
|
||||||
|
// action. Mounted alongside the /events sub-resource above; permission
|
||||||
|
// tier is customers.create, same as the rest of the customer-write
|
||||||
|
// surface.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/:id/hour-entries', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.view'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
query('status').optional().isIn(['unbilled', 'billed', 'cancelled']),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const rows = await customerHoursService.listEntries(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
{ status: req.query.status },
|
||||||
|
);
|
||||||
|
successResponse(res, { entries: rows.map(transformHourEntry) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/hour-entries', [
|
||||||
|
adminAuth,
|
||||||
|
// Migration 134 — hour entries are customer-scoped writes; same scope
|
||||||
|
// as customer record edits, narrower than invite/create.
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('entryDate').isISO8601(),
|
||||||
|
body('startTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
|
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await customerHoursService.createEntry(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
req.body,
|
||||||
|
req.admin.id,
|
||||||
|
);
|
||||||
|
successResponse(res, result, 201);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/:id/hour-entries/:entryId', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
param('entryId').isInt({ min: 1 }),
|
||||||
|
body('entryDate').optional().isISO8601(),
|
||||||
|
body('startTime').optional().matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
body('endTime').optional().matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
|
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await customerHoursService.updateEntry(
|
||||||
|
parseInt(req.params.entryId, 10),
|
||||||
|
req.body,
|
||||||
|
req.admin.id,
|
||||||
|
);
|
||||||
|
successResponse(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete('/:id/hour-entries/:entryId', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
param('entryId').isInt({ min: 1 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await customerHoursService.deleteEntry(
|
||||||
|
parseInt(req.params.entryId, 10),
|
||||||
|
req.admin.id,
|
||||||
|
);
|
||||||
|
successResponse(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/hour-entries/bill', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await customerHoursService.billUnbilledEntries(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
req.admin.id,
|
||||||
|
);
|
||||||
|
successResponse(res, result, 201);
|
||||||
|
}));
|
||||||
|
|
||||||
|
function transformHourEntry(h) {
|
||||||
|
return {
|
||||||
|
id: h.id,
|
||||||
|
customerAccountId: h.customer_account_id,
|
||||||
|
entryDate: typeof h.entry_date === 'string' ? h.entry_date.slice(0, 10) : h.entry_date,
|
||||||
|
startTime: h.start_time,
|
||||||
|
endTime: h.end_time,
|
||||||
|
durationMinutes: Number(h.duration_minutes),
|
||||||
|
hourlyRateMinorOverride: h.hourly_rate_minor_override != null ? Number(h.hourly_rate_minor_override) : null,
|
||||||
|
description: h.description,
|
||||||
|
status: h.status,
|
||||||
|
invoiceId: h.invoice_id,
|
||||||
|
invoiceLineItemId: h.invoice_line_item_id,
|
||||||
|
invoiceNumber: h.invoice_number || null,
|
||||||
|
invoiceStatus: h.invoice_status || null,
|
||||||
|
invoiceIsMonthlyDraft: h.invoice_is_monthly_draft === true || h.invoice_is_monthly_draft === 1,
|
||||||
|
invoiceScheduledSendAt: h.invoice_scheduled_send_at,
|
||||||
|
billedAt: h.billed_at,
|
||||||
|
recordedByAdminId: h.recorded_by_admin_id,
|
||||||
|
createdAt: h.created_at,
|
||||||
|
updatedAt: h.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Monthly billing — manual trigger (migration 128 admin override).
|
||||||
|
//
|
||||||
|
// Issues the customer's running monthly draft NOW, bypassing the
|
||||||
|
// scheduler's cadence-day wait. Used when admin wants to bill out-of-
|
||||||
|
// cycle (e.g. customer requested an early invoice, project completed
|
||||||
|
// before cadence day). Permission tier is customers.create — same as
|
||||||
|
// the rest of the customer-write surface and matches the rest of the
|
||||||
|
// monthly-billing controls.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
router.post('/:id/trigger-monthly-bill', [
|
||||||
|
adminAuth,
|
||||||
|
// Migration 134 — admin-override fire is a customer-scoped write,
|
||||||
|
// not a create. Roles holding customers.create were granted
|
||||||
|
// customers.edit on upgrade so this still works for existing admins.
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.triggerMonthlyBillNow(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
req.admin.id,
|
||||||
|
);
|
||||||
|
successResponse(res, result, 201);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Preview the customer's open monthly draft (line items + totals) so
|
||||||
|
// the customer-detail page can show "what will ship on the next cycle
|
||||||
|
// day". Returns null draft when nothing has been queued yet. Same
|
||||||
|
// permission scope as the trigger endpoint — both read/operate on
|
||||||
|
// the same row.
|
||||||
|
router.get('/:id/monthly-draft', [
|
||||||
|
adminAuth,
|
||||||
|
// Migration 134 — kept aligned with /trigger-monthly-bill above;
|
||||||
|
// the same role that can fire the draft should be able to preview it.
|
||||||
|
requirePermission('customers.edit'),
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
], handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const draft = await invoiceService.getMonthlyDraft(parseInt(req.params.id, 10));
|
||||||
|
successResponse(res, { draft });
|
||||||
|
}));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -353,4 +353,176 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRM overview stats — quote / invoice counts by status, rolling
|
||||||
|
* revenue windows, outstanding payments. Used by the CRM Overview
|
||||||
|
* tab at /admin/clients/overview.
|
||||||
|
*
|
||||||
|
* Permission gate: `bills.view` OR `quotes.view` — either CRM
|
||||||
|
* sub-feature unlocks the headline numbers.
|
||||||
|
*
|
||||||
|
* Currency handling: aggregates sum naively across currencies.
|
||||||
|
* Multi-currency installs get a `currency` field set to the
|
||||||
|
* business profile's default; admins running mixed-currency books
|
||||||
|
* should treat the headline figure as approximate. A multi-currency
|
||||||
|
* breakdown can be added later.
|
||||||
|
*/
|
||||||
|
router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Either CRM permission is enough — both sub-features stand
|
||||||
|
// alone (one studio may bill manually but quote via picpeak,
|
||||||
|
// another might invoice but not quote). Permissions aren't
|
||||||
|
// attached to req.admin synchronously; we have to ask the DB
|
||||||
|
// via userHasAnyPermission so our inline check matches the
|
||||||
|
// behaviour of the requirePermission middleware used elsewhere.
|
||||||
|
const { userHasAnyPermission } = require('../middleware/permissions');
|
||||||
|
const canSeeBills = await userHasAnyPermission(req.admin.id, ['bills.view']);
|
||||||
|
const canSeeQuotes = await userHasAnyPermission(req.admin.id, ['quotes.view']);
|
||||||
|
if (!canSeeBills && !canSeeQuotes) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
const monthCutoff = new Date(now - 30 * DAY);
|
||||||
|
const quarterCutoff = new Date(now - 90 * DAY);
|
||||||
|
const yearCutoff = new Date(now - 365 * DAY);
|
||||||
|
|
||||||
|
// ---- quotes: counts by status ---------------------------------
|
||||||
|
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
|
||||||
|
if (canSeeQuotes) {
|
||||||
|
try {
|
||||||
|
const rows = await db('quotes').select('status').count('id as count').groupBy('status');
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.status in quoteCounts) quoteCounts[r.status] = Number(r.count) || 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Table may not exist on installs without CRM migrations.
|
||||||
|
// Treat as all-zero so the page still renders.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- invoices: counts by status -------------------------------
|
||||||
|
let invoiceCounts = { scheduled: 0, sent: 0, paid: 0, overdue: 0, cancelled: 0 };
|
||||||
|
let revenueMonthMinor = 0;
|
||||||
|
let revenueQuarterMinor = 0;
|
||||||
|
let revenueYearMinor = 0;
|
||||||
|
let outstandingTotalMinor = 0;
|
||||||
|
let outstandingCount = 0;
|
||||||
|
|
||||||
|
if (canSeeBills) {
|
||||||
|
try {
|
||||||
|
// Same exclusions as the outstanding calc — Stornorechnungen
|
||||||
|
// and monthly drafts skew the per-status counts (Sent
|
||||||
|
// includes both real outstanding invoices and credit-note
|
||||||
|
// rows; Scheduled includes mid-period accumulators that the
|
||||||
|
// admin doesn't think of as "queued invoices" yet).
|
||||||
|
const rows = await db('invoices')
|
||||||
|
.andWhere(function() {
|
||||||
|
this.whereNot('kind', 'storno').orWhereNull('kind');
|
||||||
|
})
|
||||||
|
.andWhere(function() {
|
||||||
|
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
|
||||||
|
})
|
||||||
|
.select('status').count('id as count').groupBy('status');
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revenue windows: sum of `paid_amount_minor` for invoices
|
||||||
|
// marked PAID where paid_at falls inside the window. Using
|
||||||
|
// paid_amount (not total) so partial payments are tracked
|
||||||
|
// accurately. Stornos excluded — they're never status='paid'
|
||||||
|
// in normal flow but the guard is defensive.
|
||||||
|
const winSum = async (cutoff) => {
|
||||||
|
const row = await db('invoices')
|
||||||
|
.where('status', 'paid')
|
||||||
|
.where('paid_at', '>=', cutoff)
|
||||||
|
.andWhere(function() {
|
||||||
|
this.whereNot('kind', 'storno').orWhereNull('kind');
|
||||||
|
})
|
||||||
|
.sum('paid_amount_minor as total')
|
||||||
|
.first();
|
||||||
|
return Number(row?.total || 0);
|
||||||
|
};
|
||||||
|
revenueMonthMinor = await winSum(monthCutoff);
|
||||||
|
revenueQuarterMinor = await winSum(quarterCutoff);
|
||||||
|
revenueYearMinor = await winSum(yearCutoff);
|
||||||
|
|
||||||
|
// Outstanding: every invoice that's been sent but not fully
|
||||||
|
// paid (sent + overdue). Outstanding = total - paid. We sum
|
||||||
|
// the gap per row rather than `total - sum(paid)` so partial
|
||||||
|
// payments contribute correctly.
|
||||||
|
//
|
||||||
|
// Exclusions:
|
||||||
|
// - kind='storno' rows. Stornorechnungen carry status='sent'
|
||||||
|
// and total_amount_minor < 0; without the filter they slip
|
||||||
|
// through the status check and inflate `invoiceCount` by 1
|
||||||
|
// per Storno (the per-row gap math correctly returns 0 for
|
||||||
|
// the amount, but the row still counts). They're
|
||||||
|
// accounting-side credit notes, not money the customer
|
||||||
|
// owes.
|
||||||
|
// - is_monthly_draft=true rows. Drafts ship via the monthly
|
||||||
|
// cycle and aren't owed money until they leave draft state.
|
||||||
|
const openRows = await db('invoices')
|
||||||
|
.whereIn('status', ['sent', 'overdue'])
|
||||||
|
.andWhere(function() {
|
||||||
|
this.whereNot('kind', 'storno').orWhereNull('kind');
|
||||||
|
})
|
||||||
|
.andWhere(function() {
|
||||||
|
// Belt-and-braces: a Storno is uniquely identified by
|
||||||
|
// having `cancels_invoice_id` set (migration 114). Even
|
||||||
|
// if `kind` is somehow NULL on a Storno row, this catches
|
||||||
|
// it. NULL on regular invoices passes through unchanged.
|
||||||
|
this.whereNull('cancels_invoice_id');
|
||||||
|
})
|
||||||
|
.andWhere(function() {
|
||||||
|
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
|
||||||
|
})
|
||||||
|
.andWhere('total_amount_minor', '>=', 0)
|
||||||
|
.select('total_amount_minor', 'paid_amount_minor', 'late_fee_amount_minor');
|
||||||
|
for (const r of openRows) {
|
||||||
|
const total = Number(r.total_amount_minor || 0) + Number(r.late_fee_amount_minor || 0);
|
||||||
|
const paid = Number(r.paid_amount_minor || 0);
|
||||||
|
const gap = Math.max(0, total - paid);
|
||||||
|
if (gap > 0) {
|
||||||
|
outstandingTotalMinor += gap;
|
||||||
|
outstandingCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Table missing — treat as empty (same as quotes above).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default currency for the headline figure. Pull from
|
||||||
|
// business_profile.default_currency when present; the renderer
|
||||||
|
// accepts the same fallback chain we use elsewhere.
|
||||||
|
let currency = 'CHF';
|
||||||
|
try {
|
||||||
|
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
if (profile?.default_currency) currency = String(profile.default_currency).toUpperCase();
|
||||||
|
} catch (_) { /* leave default */ }
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currency,
|
||||||
|
quotes: quoteCounts,
|
||||||
|
invoices: invoiceCounts,
|
||||||
|
revenue: {
|
||||||
|
monthMinor: revenueMonthMinor,
|
||||||
|
quarterMinor: revenueQuarterMinor,
|
||||||
|
yearMinor: revenueYearMinor,
|
||||||
|
},
|
||||||
|
outstanding: {
|
||||||
|
totalMinor: outstandingTotalMinor,
|
||||||
|
invoiceCount: outstandingCount,
|
||||||
|
},
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
require('../utils/logger').error('CRM stats error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load CRM stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Admin → deals lineage endpoint.
|
||||||
|
*
|
||||||
|
* One UUID per customer engagement spans every quote, contract, and
|
||||||
|
* invoice (migration 140). This route exposes the union: given a
|
||||||
|
* deal_uuid, return every related document so the frontend's
|
||||||
|
* DocumentLineageCard can render the full chain with a single query
|
||||||
|
* instead of walking the legacy point-to-point FKs in JS.
|
||||||
|
*
|
||||||
|
* Read-only. The same `customers.view` permission used elsewhere for
|
||||||
|
* lineage display is the gate here — anyone who can read a quote or
|
||||||
|
* invoice detail page can read its deal lineage.
|
||||||
|
*
|
||||||
|
* Sibling routes (`/api/admin/quotes/:id/lineage`,
|
||||||
|
* `/api/admin/contracts/:id/lineage`, `/api/admin/invoices/:id/lineage`)
|
||||||
|
* also exist as conveniences so the frontend doesn't have to fetch
|
||||||
|
* the deal_uuid first; they resolve and delegate to the same service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { param, body } = require('express-validator');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const dealsService = require('../services/dealsService');
|
||||||
|
const invoiceService = require('../services/invoiceService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:uuid/documents',
|
||||||
|
requirePermission('customers.view'),
|
||||||
|
// UUID v4 format check — adminCalendar uses a similar pattern.
|
||||||
|
// Length window 32–36 covers both hyphenated and non-hyphenated
|
||||||
|
// forms; the service does the actual lookup.
|
||||||
|
[param('uuid').isString().isLength({ min: 32, max: 36 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await dealsService.getDealDocuments(req.params.uuid);
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically reshape an installment plan after siblings have spawned.
|
||||||
|
* Delegates to invoiceService.updateInstallmentPlan inside a transaction.
|
||||||
|
* See that service function for the guard/reuse/grow/trim semantics.
|
||||||
|
*
|
||||||
|
* 400 — invalid input (validator or service-side percent sum / unknown
|
||||||
|
* trigger / single-invoice deal).
|
||||||
|
* 404 — deal_uuid owns no invoices.
|
||||||
|
* 409 — at least one sibling is past `scheduled`/`pending_delivery`, or
|
||||||
|
* the deal contains a Storno.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:uuid/installment-plan',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[
|
||||||
|
param('uuid').isString().isLength({ min: 32, max: 36 }),
|
||||||
|
body('installments').isArray({ min: 1 }),
|
||||||
|
body('installments.*.percent').isFloat({ min: 0, max: 100 }),
|
||||||
|
body('installments.*.trigger').isIn([
|
||||||
|
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
|
||||||
|
]),
|
||||||
|
body('installments.*.offset_days').isInt(),
|
||||||
|
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 200 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const adminId = req.admin?.id;
|
||||||
|
const result = await db.transaction((trx) => invoiceService.updateInstallmentPlan({
|
||||||
|
trx,
|
||||||
|
dealUuid: req.params.uuid,
|
||||||
|
installments: req.body.installments,
|
||||||
|
adminId,
|
||||||
|
}));
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Dev tools
|
||||||
|
*
|
||||||
|
* Internal-use endpoints surfaced via the "Development" sub-tab
|
||||||
|
* under Clients. Strictly gated behind THREE layers:
|
||||||
|
* - admin auth + `settings.edit` permission
|
||||||
|
* - the `crmDevelopment` feature flag (defense-in-depth — the
|
||||||
|
* frontend hides the tab when off, this check stops API
|
||||||
|
* callers from poking endpoints that aren't supposed to fire)
|
||||||
|
* - the `PICPEAK_ENABLE_DEV_TOOLS=1` environment variable
|
||||||
|
* (production-safety hard gate — a stray feature flag flip in
|
||||||
|
* a real install can't enable these endpoints)
|
||||||
|
*
|
||||||
|
* Currently exposes:
|
||||||
|
* POST /send-test-email queue any CRM email template to the
|
||||||
|
* currently-logged-in admin's mailbox,
|
||||||
|
* with SYNTHETIC data only (PDFs are
|
||||||
|
* rendered from hard-coded sample data,
|
||||||
|
* never from real customer records)
|
||||||
|
*
|
||||||
|
* Security history: a prior version of this route queried real
|
||||||
|
* customer records (`SELECT … FROM quotes ORDER BY id DESC LIMIT 1`)
|
||||||
|
* to source the sample PDFs, which leaked one customer's invoice to
|
||||||
|
* a different admin's inbox in multi-admin installs. The synthetic-
|
||||||
|
* only data path closes that leak; the env gate prevents accidental
|
||||||
|
* production exposure if the feature flag is ever flipped on by
|
||||||
|
* mistake.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body } = require('express-validator');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const emailProcessor = require('../services/emailProcessor');
|
||||||
|
const pdfService = require('../services/pdfService');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-safety gate. Even if the crmDevelopment feature flag is
|
||||||
|
* accidentally enabled in a production install (one wrong DB toggle),
|
||||||
|
* this env check prevents the endpoints from doing anything. Operators
|
||||||
|
* who genuinely want dev tools in a non-production environment set
|
||||||
|
* PICPEAK_ENABLE_DEV_TOOLS=1 in the env file.
|
||||||
|
*/
|
||||||
|
router.use(handleAsync(async (req, res, next) => {
|
||||||
|
if (process.env.PICPEAK_ENABLE_DEV_TOOLS !== '1') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'CRM development tools are disabled (PICPEAK_ENABLE_DEV_TOOLS env var not set)',
|
||||||
|
code: 'CRM_DEV_ENV_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate every endpoint below the crmDevelopment feature flag.
|
||||||
|
* Mirrors the parent /admin/clients/development route guard.
|
||||||
|
*/
|
||||||
|
router.use(handleAsync(async (req, res, next) => {
|
||||||
|
const row = await db('feature_flags').where({ key: 'crmDevelopment' }).first();
|
||||||
|
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
if (!enabled) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'CRM development tools are disabled',
|
||||||
|
code: 'CRM_DEV_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEMPLATES_KEYS = [
|
||||||
|
'quote_sent',
|
||||||
|
'quote_accepted_customer',
|
||||||
|
'quote_accepted_admin',
|
||||||
|
'quote_declined_admin',
|
||||||
|
'invoice_sent',
|
||||||
|
'invoice_reminder_first',
|
||||||
|
'invoice_reminder_second',
|
||||||
|
'invoice_payment_check_admin',
|
||||||
|
// Contracts (migration 130). All three flows are exercised:
|
||||||
|
// - contract_sent: admin → customer, with a sample contract PDF
|
||||||
|
// - contract_signed_admin_notification: customer-signed ping back
|
||||||
|
// to the admin (no attachment in the real flow either)
|
||||||
|
// - contract_fully_signed: dual-party send when both signatures
|
||||||
|
// are in. Real flow attaches the stamped contract + audit cert;
|
||||||
|
// the dev tester attaches the stamped contract only (the audit
|
||||||
|
// cert is reproducible from contract data so its absence here
|
||||||
|
// doesn't change what's being tested — the template body).
|
||||||
|
'contract_sent',
|
||||||
|
'contract_signed_admin_notification',
|
||||||
|
'contract_fully_signed',
|
||||||
|
];
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/email-templates',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
handleAsync(async (_req, res) => {
|
||||||
|
// Return the keys + whether each template exists in the DB so
|
||||||
|
// the UI can grey out missing ones (e.g. on an install that
|
||||||
|
// hasn't run migration 116 yet).
|
||||||
|
const rows = await db('email_templates')
|
||||||
|
.whereIn('template_key', TEMPLATES_KEYS)
|
||||||
|
.select('template_key');
|
||||||
|
const present = new Set(rows.map((r) => r.template_key));
|
||||||
|
return successResponse(res, {
|
||||||
|
templates: TEMPLATES_KEYS.map((k) => ({ key: k, present: present.has(k) })),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
|
||||||
|
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
|
||||||
|
|
||||||
|
function fakeMoney(major, currency, locale = 'de') {
|
||||||
|
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
|
||||||
|
style: 'currency', currency: (currency || 'CHF').toUpperCase(),
|
||||||
|
}).format(major);
|
||||||
|
}
|
||||||
|
function fakeShortDate(d) {
|
||||||
|
const date = d instanceof Date ? d : new Date(d);
|
||||||
|
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the dev-test PDF directory bounded: retain only the 7 newest
|
||||||
|
* files per cleanup pass. Each test-email render writes a fresh file;
|
||||||
|
* without cleanup the directory grows unbounded.
|
||||||
|
*
|
||||||
|
* Best-effort: failures are logged and swallowed (cleanup never blocks
|
||||||
|
* the test-email flow).
|
||||||
|
*/
|
||||||
|
function pruneDevTestDir() {
|
||||||
|
try {
|
||||||
|
const dir = DEV_TEST_DIR();
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isFile() && e.name.endsWith('.pdf'))
|
||||||
|
.map((e) => {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
return { full, mtime: fs.statSync(full).mtimeMs };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime); // newest first
|
||||||
|
for (const old of entries.slice(7)) {
|
||||||
|
try { fs.unlinkSync(old.full); } catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev send-test-email: cleanup of dev-test dir failed', { err: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared synthetic issuer + recipient blocks used by all three
|
||||||
|
* sample-PDF builders. The issuer pulls from `business_profile` so
|
||||||
|
* the admin sees their own brand on the test PDF (logo, address,
|
||||||
|
* fonts) — that's the operator's own data, safe to render. The
|
||||||
|
* recipient block is fully synthetic so no customer PII is ever
|
||||||
|
* embedded.
|
||||||
|
*
|
||||||
|
* Returning `null` for issuer is acceptable; pdfService's
|
||||||
|
* normaliseContext defaults each missing field. We still fetch the
|
||||||
|
* profile when available to make the test render look realistic.
|
||||||
|
*/
|
||||||
|
async function buildSyntheticParties() {
|
||||||
|
let profile = {};
|
||||||
|
try {
|
||||||
|
const businessProfileService = require('../services/businessProfileService');
|
||||||
|
profile = (await businessProfileService.getProfile()).profile || {};
|
||||||
|
} catch (_) {
|
||||||
|
// Fresh install with no business_profile row: render with
|
||||||
|
// generic defaults below.
|
||||||
|
}
|
||||||
|
const issuer = {
|
||||||
|
companyName: profile.company_name || 'Sample Studio',
|
||||||
|
addressLine1: profile.address_line1 || 'Beispielstrasse 1',
|
||||||
|
addressLine2: profile.address_line2,
|
||||||
|
postalCode: profile.postal_code || '8000',
|
||||||
|
city: profile.city || 'Zürich',
|
||||||
|
state: profile.state,
|
||||||
|
countryCode: profile.country_code || 'CH',
|
||||||
|
phone: profile.phone,
|
||||||
|
mobile: profile.mobile,
|
||||||
|
email: profile.email || '[email protected]',
|
||||||
|
website: profile.website,
|
||||||
|
footerLine: profile.footer_line,
|
||||||
|
vatId: profile.vat_id,
|
||||||
|
logoPath: null, // skip logo file lookup for the synthetic render
|
||||||
|
pdfFontTtfPath: profile.pdf_font_ttf_path,
|
||||||
|
pdfFontFamily: profile.pdf_font_family || null,
|
||||||
|
countryName: profile.country_name || null,
|
||||||
|
showLogo: false,
|
||||||
|
showCompanyName: true,
|
||||||
|
logoHeight: 56,
|
||||||
|
companyNameInline: false,
|
||||||
|
foldingMarks: 'none',
|
||||||
|
quoteShowNetDays: false,
|
||||||
|
quoteShowSkonto: false,
|
||||||
|
};
|
||||||
|
const recipient = {
|
||||||
|
issuerLine: profile.company_name
|
||||||
|
? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}`
|
||||||
|
: '',
|
||||||
|
companyName: 'Sample Customer GmbH',
|
||||||
|
hasCompany: true,
|
||||||
|
attentionLine: 'z. Hd. Maria Sample',
|
||||||
|
salutation: 'Frau',
|
||||||
|
lastName: 'Sample',
|
||||||
|
addressLine1: 'Musterstrasse 1',
|
||||||
|
addressLine2: null,
|
||||||
|
postalCode: '8000',
|
||||||
|
city: 'Zürich',
|
||||||
|
country: null,
|
||||||
|
countryCodeIso: 'CH',
|
||||||
|
};
|
||||||
|
return { issuer, recipient };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYNTHETIC_LINE_ITEMS = [
|
||||||
|
{ quantity: 1, description: 'Photo session (sample)', unitPriceMinor: 80000, discountPercent: 0, lineTotalMinor: 80000, parentLineItemId: null, parentPosition: null, detailsText: null },
|
||||||
|
{ quantity: 2, description: 'Photo prints A4 (sample)', unitPriceMinor: 1500, discountPercent: 0, lineTotalMinor: 3000, parentLineItemId: null, parentPosition: null, detailsText: null },
|
||||||
|
];
|
||||||
|
const SYNTHETIC_TOTALS = { netAmountMinor: 83000, vatRate: 7.7, vatAmountMinor: 6391, shippingAmountMinor: 0, totalAmountMinor: 89391 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a sample QUOTE PDF from synthetic data — no DB read of real
|
||||||
|
* quotes. Uses pdfService.renderQuoteToBuffer directly with a render
|
||||||
|
* context matching the shape produced by quoteService.buildRenderContext.
|
||||||
|
*/
|
||||||
|
async function renderSyntheticQuotePdf(adminId) {
|
||||||
|
try {
|
||||||
|
const { issuer, recipient } = await buildSyntheticParties();
|
||||||
|
const today = new Date();
|
||||||
|
const ctx = {
|
||||||
|
locale: 'de',
|
||||||
|
currency: 'CHF',
|
||||||
|
qrFormat: 'none',
|
||||||
|
issuer,
|
||||||
|
recipient,
|
||||||
|
lineItems: SYNTHETIC_LINE_ITEMS,
|
||||||
|
totals: SYNTHETIC_TOTALS,
|
||||||
|
doc: {
|
||||||
|
quoteNumber: 'Q-DEV-0001',
|
||||||
|
issueDate: today,
|
||||||
|
validUntil: new Date(today.getTime() + 14 * 86400000),
|
||||||
|
introText: 'Sample quote — synthetic data only. Not a real customer record.',
|
||||||
|
outroText: null,
|
||||||
|
totalAmountMinor: SYNTHETIC_TOTALS.totalAmountMinor,
|
||||||
|
},
|
||||||
|
bank: null,
|
||||||
|
paymentTerm: null,
|
||||||
|
};
|
||||||
|
const buffer = await pdfService.renderQuoteToBuffer(ctx);
|
||||||
|
return writeSyntheticPdf(buffer, `quote-sample-${adminId}-${Date.now()}.pdf`, 'Q-DEV-0001-sample.pdf');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev send-test-email: synthetic quote PDF render failed', { err: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSyntheticInvoicePdf(adminId) {
|
||||||
|
try {
|
||||||
|
const { issuer, recipient } = await buildSyntheticParties();
|
||||||
|
const today = new Date();
|
||||||
|
const ctx = {
|
||||||
|
locale: 'de',
|
||||||
|
currency: 'CHF',
|
||||||
|
qrFormat: 'none',
|
||||||
|
issuer,
|
||||||
|
recipient,
|
||||||
|
lineItems: SYNTHETIC_LINE_ITEMS,
|
||||||
|
totals: SYNTHETIC_TOTALS,
|
||||||
|
doc: {
|
||||||
|
invoiceNumber: 'R-DEV-0001',
|
||||||
|
issueDate: today,
|
||||||
|
dueDate: new Date(today.getTime() + 30 * 86400000),
|
||||||
|
introText: 'Sample invoice — synthetic data only. Not a real customer record.',
|
||||||
|
outroText: null,
|
||||||
|
kind: 'invoice',
|
||||||
|
lateFeeMinor: 0,
|
||||||
|
},
|
||||||
|
bank: null,
|
||||||
|
paymentTerm: null,
|
||||||
|
};
|
||||||
|
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
|
||||||
|
return writeSyntheticPdf(buffer, `invoice-sample-${adminId}-${Date.now()}.pdf`, 'R-DEV-0001-sample.pdf');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev send-test-email: synthetic invoice PDF render failed', { err: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSyntheticContractPdf(adminId) {
|
||||||
|
try {
|
||||||
|
const { issuer, recipient } = await buildSyntheticParties();
|
||||||
|
const today = new Date();
|
||||||
|
const ctx = {
|
||||||
|
locale: 'de',
|
||||||
|
dateFormat: null,
|
||||||
|
issuer,
|
||||||
|
recipient,
|
||||||
|
today,
|
||||||
|
doc: {
|
||||||
|
contractNumber: 'C-DEV-0001',
|
||||||
|
title: 'Sample contract — synthetic data only',
|
||||||
|
issueDate: today,
|
||||||
|
validUntil: new Date(today.getTime() + 30 * 86400000),
|
||||||
|
introText: null,
|
||||||
|
outroText: null,
|
||||||
|
},
|
||||||
|
sections: [{
|
||||||
|
section: 'basics',
|
||||||
|
blocks: [{
|
||||||
|
slug: 'basics_service',
|
||||||
|
name: 'Subject of contract (sample)',
|
||||||
|
section: 'basics',
|
||||||
|
body: 'This is a synthetic dev-test contract. Not a real customer agreement.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
signatures: { customer: null, admin: null },
|
||||||
|
};
|
||||||
|
const buffer = await pdfService.renderContractToBuffer(ctx);
|
||||||
|
return writeSyntheticPdf(buffer, `contract-sample-${adminId}-${Date.now()}.pdf`, 'C-DEV-0001-sample.pdf');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('dev send-test-email: synthetic contract PDF render failed', { err: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSyntheticPdf(buffer, onDiskName, attachmentName) {
|
||||||
|
const dir = DEV_TEST_DIR();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const filePath = path.join(dir, onDiskName);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
pruneDevTestDir();
|
||||||
|
return { path: filePath, filename: attachmentName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a payload tailored to each template. All variables map back
|
||||||
|
* to the `{{tokens}}` the seeded templates reference, so the email
|
||||||
|
* the admin sees is identical to what the real flow would send.
|
||||||
|
*/
|
||||||
|
async function buildPayloadFor(key, adminId, frontendUrl) {
|
||||||
|
const dummyToken = 'dev-test-token-' + Math.random().toString(16).slice(2, 12).padEnd(64, '0').slice(0, 64);
|
||||||
|
const total = 1234.56;
|
||||||
|
const lateFee = 25.00;
|
||||||
|
const today = new Date();
|
||||||
|
const dueDate = new Date(today.getTime() - 5 * 86400000);
|
||||||
|
const validUntil = new Date(today.getTime() + 14 * 86400000);
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
customer_name: 'Sample Customer',
|
||||||
|
customer_email: '[email protected]',
|
||||||
|
event_name: 'Sample Event',
|
||||||
|
invoice_number: 'R-DEV-0001',
|
||||||
|
quote_number: 'Q-DEV-0001',
|
||||||
|
total_amount: fakeMoney(total, 'CHF'),
|
||||||
|
new_total_amount: fakeMoney(total + lateFee, 'CHF'),
|
||||||
|
late_fee_amount: fakeMoney(lateFee, 'CHF'),
|
||||||
|
late_fee_due: true,
|
||||||
|
due_date: fakeShortDate(dueDate),
|
||||||
|
valid_until: fakeShortDate(validUntil),
|
||||||
|
days_overdue: 5,
|
||||||
|
installment_label: 'Anzahlung',
|
||||||
|
installment_index: 1,
|
||||||
|
installment_total: 2,
|
||||||
|
admin_dashboard_url: `${frontendUrl}/admin/clients/bills`,
|
||||||
|
response_url: `${frontendUrl}/quote/${dummyToken}`,
|
||||||
|
accept_url: `${frontendUrl}/quote/${dummyToken}?action=accept`,
|
||||||
|
decline_url: `${frontendUrl}/quote/${dummyToken}?action=decline`,
|
||||||
|
paid_url: `${frontendUrl}/payment-check/${dummyToken}?action=paid_full`,
|
||||||
|
partial_url: `${frontendUrl}/payment-check/${dummyToken}?action=partial`,
|
||||||
|
unpaid_url: `${frontendUrl}/payment-check/${dummyToken}?action=unpaid`,
|
||||||
|
accepted_on_behalf: true,
|
||||||
|
// Contract-specific variables. Title + contract_number stand in
|
||||||
|
// for the matching {{tokens}} in the seeded contract templates.
|
||||||
|
contract_number: 'C-DEV-0001',
|
||||||
|
title: 'Sample contract — synthetic data only',
|
||||||
|
signed_customer_name: 'Sample Customer',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Templates with PDF attachments get a SYNTHETIC sample PDF. Never
|
||||||
|
// pulls from real records on disk — every render builds from
|
||||||
|
// hardcoded sample data via the renderSynthetic*Pdf helpers above.
|
||||||
|
let attachments;
|
||||||
|
if (key === 'quote_sent' || key === 'quote_accepted_customer') {
|
||||||
|
const pdf = await renderSyntheticQuotePdf(adminId);
|
||||||
|
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
|
||||||
|
} else if (key === 'invoice_sent' || key === 'invoice_reminder_first' || key === 'invoice_reminder_second') {
|
||||||
|
const pdf = await renderSyntheticInvoicePdf(adminId);
|
||||||
|
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
|
||||||
|
} else if (key === 'contract_sent' || key === 'contract_fully_signed') {
|
||||||
|
const pdf = await renderSyntheticContractPdf(adminId);
|
||||||
|
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments ? { ...common, attachments } : common;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/send-test-email',
|
||||||
|
requirePermission('settings.edit'),
|
||||||
|
[body('templateKey').isString().isIn(TEMPLATES_KEYS)],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const admin = await db('admin_users').where({ id: req.admin.id }).first();
|
||||||
|
if (!admin?.email) throw new AppError('Logged-in admin has no email on file', 400);
|
||||||
|
|
||||||
|
const template = await db('email_templates')
|
||||||
|
.where({ template_key: req.body.templateKey }).first();
|
||||||
|
if (!template) {
|
||||||
|
throw new AppError(`Template "${req.body.templateKey}" not seeded yet — run migrations`, 409, 'TEMPLATE_MISSING');
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendUrl = (process.env.FRONTEND_URL || FRONTEND_URL_FALLBACK).replace(/\/$/, '');
|
||||||
|
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
|
||||||
|
|
||||||
|
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
sent: true,
|
||||||
|
to: admin.email,
|
||||||
|
template: req.body.templateKey,
|
||||||
|
}, 200, 'Test email queued');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -303,6 +303,16 @@ async function getTemplateTranslations(templateId, template) {
|
|||||||
// Get email templates
|
// Get email templates
|
||||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Self-heal: ensure the seeded event-reminder templates exist + are
|
||||||
|
// backfilled with example content on already-migrated installs. The
|
||||||
|
// function is idempotent and short-circuits via a module-level cache
|
||||||
|
// after one successful pass, so this is free on subsequent calls.
|
||||||
|
try {
|
||||||
|
const { ensureEventReminderTemplatesSeeded } = require('../services/eventReminderTemplates');
|
||||||
|
const log = require('../utils/logger');
|
||||||
|
await ensureEventReminderTemplatesSeeded(db, log);
|
||||||
|
} catch (_e) { /* non-fatal */ }
|
||||||
|
|
||||||
const templates = await db('email_templates')
|
const templates = await db('email_templates')
|
||||||
.select('*')
|
.select('*')
|
||||||
.orderBy('template_key');
|
.orderBy('template_key');
|
||||||
@@ -452,6 +462,94 @@ router.put('/templates/:key', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a new email template. Used by the ReminderTemplatesPage to
|
||||||
|
// mint a per-event-type reminder (template_key like
|
||||||
|
// `event_reminder_<slug_prefix>`). Idempotent at the API level — if
|
||||||
|
// the key already exists we return 409 so the caller knows to PUT
|
||||||
|
// instead.
|
||||||
|
router.post('/templates', [
|
||||||
|
adminAuth,
|
||||||
|
requirePermission('email.edit'),
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
template_key: templateKey,
|
||||||
|
translations,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
feature_flag: featureFlag,
|
||||||
|
variables,
|
||||||
|
} = req.body;
|
||||||
|
if (!templateKey || typeof templateKey !== 'string' || !/^[a-z0-9_]+$/.test(templateKey)) {
|
||||||
|
return res.status(400).json({ error: 'template_key must be a snake_case identifier' });
|
||||||
|
}
|
||||||
|
if (!translations || typeof translations !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'translations object is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db('email_templates').where({ template_key: templateKey }).first();
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Template already exists. Use PUT /templates/:key to update.',
|
||||||
|
code: 'TEMPLATE_EXISTS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = await db('email_templates').columnInfo();
|
||||||
|
const enContent = translations.en || {};
|
||||||
|
|
||||||
|
// Build the master row. The legacy single-row columns are populated
|
||||||
|
// from EN so older readers that don't consult the translations
|
||||||
|
// table still see something sensible.
|
||||||
|
const masterRow = { template_key: templateKey };
|
||||||
|
if (variables && 'variables' in cols) masterRow.variables = JSON.stringify(variables);
|
||||||
|
if (category && 'category' in cols) masterRow.category = category;
|
||||||
|
if (subcategory && 'subcategory' in cols) masterRow.subcategory = subcategory;
|
||||||
|
if (featureFlag && 'feature_flag' in cols) masterRow.feature_flag = featureFlag;
|
||||||
|
if ('created_at' in cols) masterRow.created_at = new Date();
|
||||||
|
if ('updated_at' in cols) masterRow.updated_at = new Date();
|
||||||
|
for (const colName of Object.keys(cols)) {
|
||||||
|
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.subject || '';
|
||||||
|
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.body_html || '';
|
||||||
|
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.body_text || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db('email_templates').insert(masterRow).returning('id');
|
||||||
|
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
// Per-language rows in email_template_translations.
|
||||||
|
const hasTranslations = await db.schema.hasTable('email_template_translations');
|
||||||
|
if (hasTranslations && templateId) {
|
||||||
|
for (const [language, content] of Object.entries(translations)) {
|
||||||
|
if (!content || typeof content !== 'object') continue;
|
||||||
|
await db('email_template_translations').insert({
|
||||||
|
template_id: templateId,
|
||||||
|
language,
|
||||||
|
subject: content.subject || '',
|
||||||
|
body_html: content.body_html || '',
|
||||||
|
body_text: content.body_text || '',
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity('email_template_created',
|
||||||
|
{ template_key: templateKey, languages: Object.keys(translations) },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||||
|
|
||||||
|
return res.status(201).json({ template_key: templateKey, id: templateId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email template create error:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to create email template' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Preview email template
|
// Preview email template
|
||||||
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
|
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const logger = require('../utils/logger');
|
|||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
const eventTypeService = require('../services/eventTypeService');
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
|
const { normaliseEventTimeTriple } = require('../services/eventService');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
const { requireEventOwnership } = require('../middleware/ownership');
|
||||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||||
@@ -334,6 +336,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
}),
|
}),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').optional({ values: 'falsy' }).isDate(),
|
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||||
|
// Migration 137 — calendar time fields.
|
||||||
|
body('event_time_start').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||||
|
.withMessage('event_time_start must be HH:MM 24h'),
|
||||||
|
body('event_time_end').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||||
|
.withMessage('event_time_end must be HH:MM 24h'),
|
||||||
|
body('is_full_day').optional().isBoolean().toBoolean(),
|
||||||
body('customer_name').optional().trim(),
|
body('customer_name').optional().trim(),
|
||||||
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||||
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
||||||
@@ -425,6 +433,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
|
// Migration 137 — calendar time fields. is_full_day defaults to
|
||||||
|
// true at the service layer when undefined (legacy form payloads).
|
||||||
|
event_time_start,
|
||||||
|
event_time_end,
|
||||||
|
is_full_day,
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
welcome_message = '',
|
welcome_message = '',
|
||||||
@@ -634,12 +647,24 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
? protectionDefaults.enable_devtools_protection
|
? protectionDefaults.enable_devtools_protection
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
|
// Migration 137 — normalise calendar time triple. Throws AppError
|
||||||
|
// 400 when is_full_day=false but times are malformed/inverted.
|
||||||
|
const calendarTriple = normaliseEventTimeTriple({
|
||||||
|
event_time_start, event_time_end, is_full_day,
|
||||||
|
});
|
||||||
|
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database
|
||||||
const insertResult = await db('events').insert({
|
const insertResult = await db('events').insert({
|
||||||
slug,
|
slug,
|
||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date: event_date || null,
|
event_date: event_date || null,
|
||||||
|
...(calendarColumnsExist ? {
|
||||||
|
event_time_start: calendarTriple.event_time_start,
|
||||||
|
event_time_end: calendarTriple.event_time_end,
|
||||||
|
is_full_day: formatBoolean(calendarTriple.is_full_day),
|
||||||
|
} : {}),
|
||||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||||
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
||||||
host_name: customerName || null,
|
host_name: customerName || null,
|
||||||
@@ -1102,12 +1127,30 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
|
|||||||
// Update event
|
// Update event
|
||||||
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||||
body('event_name').optional().trim().notEmpty(),
|
body('event_name').optional().trim().notEmpty(),
|
||||||
|
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||||
|
// Migration 137 — calendar time fields. Same regex/range rule as POST.
|
||||||
|
body('event_time_start').optional({ values: 'falsy', nullable: true })
|
||||||
|
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||||
|
.withMessage('event_time_start must be HH:MM 24h'),
|
||||||
|
body('event_time_end').optional({ values: 'falsy', nullable: true })
|
||||||
|
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||||
|
.withMessage('event_time_end must be HH:MM 24h'),
|
||||||
|
body('is_full_day').optional().isBoolean().toBoolean(),
|
||||||
body('admin_email').optional().isEmail(),
|
body('admin_email').optional().isEmail(),
|
||||||
body('is_active').optional().isBoolean(),
|
body('is_active').optional().isBoolean(),
|
||||||
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||||
body('color_theme').optional({ nullable: true }),
|
body('color_theme').optional({ nullable: true }),
|
||||||
body('allow_user_uploads').optional().isBoolean(),
|
body('allow_user_uploads').optional().isBoolean(),
|
||||||
|
// Migration 143 — per-event reminder overrides. All three are
|
||||||
|
// optional; nullable values are accepted so admins can clear an
|
||||||
|
// override (e.g. drop a custom offset back to the global default).
|
||||||
|
body('event_reminder_disabled').optional().isBoolean(),
|
||||||
|
body('event_reminder_offset_days').optional({ nullable: true })
|
||||||
|
.custom((v) => v === null || (Number.isInteger(Number(v)) && Number(v) >= 0))
|
||||||
|
.withMessage('event_reminder_offset_days must be a non-negative integer or null'),
|
||||||
|
body('event_reminder_body_override').optional({ nullable: true, checkFalsy: true })
|
||||||
|
.isString().isLength({ max: 10_000 }),
|
||||||
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
|
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||||
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
||||||
@@ -1291,6 +1334,32 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
|||||||
// the entire edit with 500 Failed to update event.
|
// the entire edit with 500 Failed to update event.
|
||||||
delete updates.customer_account_ids;
|
delete updates.customer_account_ids;
|
||||||
|
|
||||||
|
// Migration 137 — calendar time triple. Renormalise only when at
|
||||||
|
// least one of the three fields was supplied; otherwise leave the
|
||||||
|
// row's current values alone. is_full_day=true forces both times
|
||||||
|
// to null. Drop the fields silently on un-migrated installs.
|
||||||
|
const timeFieldsTouched = (
|
||||||
|
Object.prototype.hasOwnProperty.call(updates, 'event_time_start')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(updates, 'event_time_end')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(updates, 'is_full_day')
|
||||||
|
);
|
||||||
|
if (timeFieldsTouched) {
|
||||||
|
if (await hasColumnCached('events', 'is_full_day')) {
|
||||||
|
const triple = normaliseEventTimeTriple({
|
||||||
|
event_time_start: updates.event_time_start,
|
||||||
|
event_time_end: updates.event_time_end,
|
||||||
|
is_full_day: updates.is_full_day,
|
||||||
|
});
|
||||||
|
updates.event_time_start = triple.event_time_start;
|
||||||
|
updates.event_time_end = triple.event_time_end;
|
||||||
|
updates.is_full_day = formatBoolean(triple.is_full_day);
|
||||||
|
} else {
|
||||||
|
delete updates.event_time_start;
|
||||||
|
delete updates.event_time_end;
|
||||||
|
delete updates.is_full_day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log the update request for debugging
|
// Log the update request for debugging
|
||||||
logger.debug('Update event request', {
|
logger.debug('Update event request', {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -41,13 +41,38 @@ const KNOWN_FLAGS = [
|
|||||||
// Customer-side portal surface (#354). Gates /customer/* routes
|
// Customer-side portal surface (#354). Gates /customer/* routes
|
||||||
// and the Accounts sub-page under Clients. See migration 095.
|
// and the Accounts sub-page under Clients. See migration 095.
|
||||||
'customerPortal',
|
'customerPortal',
|
||||||
|
// CRM developer tools sub-tab — internal helpers (test the
|
||||||
|
// payment-check email flow without waiting 30 days, etc.).
|
||||||
|
// Strictly opt-in.
|
||||||
|
'crmDevelopment',
|
||||||
|
// Tax / Steuer report sub-tab under Clients. Independent toggle so
|
||||||
|
// admins who use Bills but don't need the tax export (or aren't
|
||||||
|
// ready to enable it yet) can leave it off. Forced off when `bills`
|
||||||
|
// is off (no invoices → nothing to report).
|
||||||
|
'taxReport',
|
||||||
|
// Hours logging (migration 129). Master switch for the per-customer
|
||||||
|
// Hours card + the auto-append into monthly draft / "Bill these
|
||||||
|
// hours" flow. Independent of `bills` because hours are an INPUT to
|
||||||
|
// bills — admin who's still in the dogfood phase may want to log
|
||||||
|
// hours without enabling the full billing surface yet.
|
||||||
|
'hoursLogging',
|
||||||
|
// Contracts (migration 130). Independent of quotes/bills — contracts
|
||||||
|
// are a standalone legal document type with their own composition
|
||||||
|
// (blocks) and signing flow (in-browser canvas + wet-signed PDF
|
||||||
|
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
|
||||||
|
// lawyer review before sending. See docs/crm-disclaimers.md.
|
||||||
|
'contracts',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Spec defaults for any flag missing from the DB (e.g. a row added by a
|
// Spec defaults for any flag missing from the DB (e.g. a row added by a
|
||||||
// new release that hasn't run its migration yet on this instance).
|
// new release that hasn't run its migration yet on this instance).
|
||||||
const DEFAULT_FLAGS = {
|
const DEFAULT_FLAGS = {
|
||||||
galleries: true,
|
galleries: true,
|
||||||
reminderEmails: true,
|
// F.3 — reminderEmails is a placeholder card in the Features tab
|
||||||
|
// (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches
|
||||||
|
// the locked-but-off visual state of messaging / calendarBooking
|
||||||
|
// instead of being a confusing "on but locked".
|
||||||
|
reminderEmails: false,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
calendarBooking: false,
|
calendarBooking: false,
|
||||||
quotes: false,
|
quotes: false,
|
||||||
@@ -56,6 +81,9 @@ const DEFAULT_FLAGS = {
|
|||||||
analytics: true,
|
analytics: true,
|
||||||
userManagement: true,
|
userManagement: true,
|
||||||
clients: false,
|
clients: false,
|
||||||
|
taxReport: false,
|
||||||
|
hoursLogging: false,
|
||||||
|
contracts: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readAllFlags() {
|
async function readAllFlags() {
|
||||||
@@ -76,6 +104,10 @@ function applyDependencyRules(flags) {
|
|||||||
// Sub-features can't outlive their parents.
|
// Sub-features can't outlive their parents.
|
||||||
if (out.quotes === false) out.bills = false;
|
if (out.quotes === false) out.bills = false;
|
||||||
if (out.calendar === false) out.calendarBooking = false;
|
if (out.calendar === false) out.calendarBooking = false;
|
||||||
|
// Tax report only makes sense when bills are on — turning bills off
|
||||||
|
// implicitly turns the tax report off too. Admins enabling tax
|
||||||
|
// report must first enable bills.
|
||||||
|
if (out.bills === false) out.taxReport = false;
|
||||||
// Clients parent flag is DERIVED from its children. Admins don't
|
// Clients parent flag is DERIVED from its children. Admins don't
|
||||||
// toggle it directly in the Features tab — they enable a specific
|
// toggle it directly in the Features tab — they enable a specific
|
||||||
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
|
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
|
||||||
@@ -85,7 +117,17 @@ function applyDependencyRules(flags) {
|
|||||||
// ever drifts (e.g. partial migration run).
|
// ever drifts (e.g. partial migration run).
|
||||||
out.clients = Boolean(
|
out.clients = Boolean(
|
||||||
out.customerPortal
|
out.customerPortal
|
||||||
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
|
|| out.crmDevelopment
|
||||||
|
|| out.quotes
|
||||||
|
|| out.bills
|
||||||
|
|| out.taxReport
|
||||||
|
|| out.hoursLogging
|
||||||
|
|| out.contracts
|
||||||
|
// Migration 137 — admin calendar lights up the Clients section.
|
||||||
|
// (calendarBooking is gated behind `calendar` so adding the parent
|
||||||
|
// is sufficient.)
|
||||||
|
|| out.calendar
|
||||||
|
// future siblings (out.messaging) go here
|
||||||
);
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,882 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Invoices Routes
|
||||||
|
*
|
||||||
|
* Endpoint mounted at /api/admin/invoices. Surface:
|
||||||
|
* GET / list (filter + sort + paginate)
|
||||||
|
* POST / create (status=scheduled or sent)
|
||||||
|
* GET /:id detail incl. line items + payments
|
||||||
|
* PUT /:id update (only when not paid/cancelled)
|
||||||
|
* POST /:id/send render PDF + queue email now
|
||||||
|
* POST /:id/mark-paid record a payment
|
||||||
|
* POST /:id/send-reminder manually trigger reminder ladder
|
||||||
|
* POST /:id/cancel cancel a non-paid invoice
|
||||||
|
* GET /:id/pdf preview / download PDF
|
||||||
|
* GET /:id/payment-log list payment log entries
|
||||||
|
* POST /preview render PDF from unsaved payload
|
||||||
|
*
|
||||||
|
* Permissions: `bills.view` for reads, `bills.manage` for writes.
|
||||||
|
* Global `bills` feature flag enforced at the route layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param, query } = require('express-validator');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { getStoragePath } = require('../config/storage');
|
||||||
|
const invoiceService = require('../services/invoiceService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Multer config for "import historical invoice" PDF uploads. Stored
|
||||||
|
// under storage/business-docs/invoice-imports/<year>/<filename> so
|
||||||
|
// imported files don't collide with the renderer's own output under
|
||||||
|
// storage/business-docs/invoice/<year>/. PDF-only, 10MB cap.
|
||||||
|
const importedInvoiceStorage = multer.diskStorage({
|
||||||
|
destination: async (_req, _file, cb) => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const dir = path.join(getStoragePath(), 'business-docs', 'invoice-imports', String(year));
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname) || '.pdf';
|
||||||
|
cb(null, `imported-${Date.now()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const importedInvoiceUpload = multer({
|
||||||
|
storage: importedInvoiceStorage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (file.mimetype === 'application/pdf') cb(null, true);
|
||||||
|
else cb(new Error('Only PDF files are allowed for imported invoices'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireBillsFlag(req, res, next) {
|
||||||
|
try {
|
||||||
|
const row = await db('feature_flags').where({ key: 'bills' }).first();
|
||||||
|
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
if (!enabled) return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use(requireBillsFlag);
|
||||||
|
|
||||||
|
function transformInvoice(i) {
|
||||||
|
if (!i) return null;
|
||||||
|
return {
|
||||||
|
id: i.id,
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
customerAccountId: i.customer_account_id,
|
||||||
|
customer: {
|
||||||
|
email: i.customer_email,
|
||||||
|
displayName: i.customer_display_name,
|
||||||
|
firstName: i.customer_first_name,
|
||||||
|
lastName: i.customer_last_name,
|
||||||
|
companyName: i.customer_company_name,
|
||||||
|
// Passive customers (admin-only, no portal access) are
|
||||||
|
// identified by a null password_hash. We expose just the
|
||||||
|
// boolean — the hash itself is dropped here.
|
||||||
|
isPassive: i.customer_password_hash == null,
|
||||||
|
},
|
||||||
|
// Migration 140 — cross-document lineage UUID. See adminQuotes
|
||||||
|
// transform for the rationale; lets the lineage card pull the
|
||||||
|
// whole deal in one query.
|
||||||
|
dealUuid: i.deal_uuid || null,
|
||||||
|
sourceQuoteId: i.source_quote_id,
|
||||||
|
sourceQuoteNumber: i.source_quote_number || null,
|
||||||
|
// Migration 130 lineage: set by contractService.convertToInvoiceOnly
|
||||||
|
// so BillDetailPage can render a "From contract" badge. The number
|
||||||
|
// (e.g. LBM-C-2026-0010) comes from the src_contract JOIN; the id
|
||||||
|
// is kept as a fallback for invoices generated before the JOIN
|
||||||
|
// was wired in.
|
||||||
|
sourceContractId: i.source_contract_id || null,
|
||||||
|
sourceContractNumber: i.source_contract_number || null,
|
||||||
|
eventId: i.event_id,
|
||||||
|
language: i.language,
|
||||||
|
currency: i.currency,
|
||||||
|
issueDate: i.issue_date,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
installmentIndex: i.installment_index,
|
||||||
|
installmentTotal: i.installment_total,
|
||||||
|
installmentLabel: i.installment_label,
|
||||||
|
installmentTrigger: i.installment_trigger,
|
||||||
|
status: i.status,
|
||||||
|
scheduledSendAt: i.scheduled_send_at,
|
||||||
|
sentAt: i.sent_at,
|
||||||
|
netAmountMinor: i.net_amount_minor,
|
||||||
|
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
|
||||||
|
vatAmountMinor: i.vat_amount_minor,
|
||||||
|
shippingAmountMinor: i.shipping_amount_minor,
|
||||||
|
totalAmountMinor: i.total_amount_minor,
|
||||||
|
paidAmountMinor: i.paid_amount_minor,
|
||||||
|
paidAt: i.paid_at,
|
||||||
|
paymentMethod: i.payment_method,
|
||||||
|
paymentReference: i.payment_reference,
|
||||||
|
reminderLevel: i.reminder_level,
|
||||||
|
lastReminderSentAt: i.last_reminder_sent_at,
|
||||||
|
lateFeeAmountMinor: i.late_fee_amount_minor,
|
||||||
|
ccPdfEmail: i.cc_pdf_email,
|
||||||
|
qrFormat: i.qr_format,
|
||||||
|
pdfPath: i.pdf_path,
|
||||||
|
businessBankAccountId: i.business_bank_account_id,
|
||||||
|
paymentTermTemplateId: i.payment_term_template_id || null,
|
||||||
|
// Split payment-term picker (migration 124). Two new FKs; the
|
||||||
|
// editor prefers these. Both must be present for the new path to
|
||||||
|
// engage server-side.
|
||||||
|
paymentNetDaysTemplateId: i.payment_net_days_template_id || null,
|
||||||
|
paymentTimingTemplateId: i.payment_timing_template_id || null,
|
||||||
|
// Migration 126 — per-invoice Skonto opt-out. Editor surfaces
|
||||||
|
// this as a checkbox so admin can suppress the discount for one
|
||||||
|
// invoice without touching the template or global default.
|
||||||
|
skontoDisabled: i.skonto_disabled === true || i.skonto_disabled === 1,
|
||||||
|
// Monthly billing (migration 128). isMonthlyDraft=true marks the
|
||||||
|
// accumulator the editor's banner + save-button-label react to.
|
||||||
|
// monthlyPeriodStart/End drive the period banner on the customer
|
||||||
|
// detail page and (later) the PDF header.
|
||||||
|
isMonthlyDraft: i.is_monthly_draft === true || i.is_monthly_draft === 1,
|
||||||
|
monthlyPeriodStart: i.monthly_period_start || null,
|
||||||
|
monthlyPeriodEnd: i.monthly_period_end || null,
|
||||||
|
// Storno wiring (migration 114). The four FK columns drive the
|
||||||
|
// admin UI's banners + action gating:
|
||||||
|
// - kind: 'invoice' | 'storno' — defaults to 'invoice' for rows
|
||||||
|
// seeded before the column existed (legacy installs).
|
||||||
|
// - replacesInvoiceId: on a reissued invoice → original cancelled id.
|
||||||
|
// - cancelsInvoiceId: on a Storno row → invoice it reverses.
|
||||||
|
// - cancellationStornoId: on a cancelled original → Storno that
|
||||||
|
// cancelled it (so the detail view can link forward).
|
||||||
|
kind: i.kind || 'invoice',
|
||||||
|
replacesInvoiceId: i.replaces_invoice_id || null,
|
||||||
|
cancelsInvoiceId: i.cancels_invoice_id || null,
|
||||||
|
cancelsInvoiceNumber: i.cancels_invoice_number || null,
|
||||||
|
cancellationStornoId: i.cancellation_storno_id || null,
|
||||||
|
cancellationStornoNumber: i.cancellation_storno_number || null,
|
||||||
|
// Inline event snapshot (migration 123). The editor binds to
|
||||||
|
// these, the list page shows event_name as a column, and email
|
||||||
|
// / tax-report rendering reads them in preference to the FK.
|
||||||
|
eventName: i.event_name || null,
|
||||||
|
eventDate: i.event_date || null,
|
||||||
|
eventTimeStart: i.event_time_start || null,
|
||||||
|
eventTimeEnd: i.event_time_end || null,
|
||||||
|
// `isImported` surfaces the historical-PDF flag to the admin UI
|
||||||
|
// so the list / detail page can hide line-item editing on rows
|
||||||
|
// that originated from a different billing system (migration 111).
|
||||||
|
isImported: !!i.imported_pdf_path,
|
||||||
|
createdAt: i.created_at,
|
||||||
|
updatedAt: i.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLineItem(li) {
|
||||||
|
return {
|
||||||
|
id: li.id,
|
||||||
|
position: li.position,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
description: li.description,
|
||||||
|
unitPriceMinor: li.unit_price_minor,
|
||||||
|
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
|
||||||
|
lineTotalMinor: li.line_total_minor,
|
||||||
|
// Hierarchy (migration 119). parentPosition comes from the
|
||||||
|
// self-join in getInvoiceById; parentLineItemId is the raw FK.
|
||||||
|
// detailsText is the optional free-form notes block rendered
|
||||||
|
// below the description on the PDF and customer view.
|
||||||
|
parentLineItemId: li.parent_line_item_id || null,
|
||||||
|
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
|
||||||
|
detailsText: li.details_text || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPaymentLog(p) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
amountMinor: p.amount_minor,
|
||||||
|
paidAt: p.paid_at,
|
||||||
|
paymentMethod: p.payment_method,
|
||||||
|
reference: p.reference,
|
||||||
|
notes: p.notes,
|
||||||
|
recordedByAdminId: p.recorded_by_admin_id,
|
||||||
|
createdAt: p.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOICE_BODY_VALIDATORS = [
|
||||||
|
body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('scheduledSendAt').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('installmentIndex').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('installmentTotal').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('installmentLabel').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('installmentTrigger').optional({ values: 'falsy' }).isString().isLength({ max: 32 }),
|
||||||
|
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('qrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
|
||||||
|
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
// Split payment-term picker (migration 124). Both optional at the
|
||||||
|
// validator level so legacy clients still work; the editor will
|
||||||
|
// require them once it's updated.
|
||||||
|
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
// Ad-hoc installments override (commit #6 of the deal_uuid PR).
|
||||||
|
// When the array has ≥2 rows with percent>0, createInvoice routes
|
||||||
|
// through spawnInstallmentInvoices (commit #4) and returns
|
||||||
|
// invoiceIds[].
|
||||||
|
body('installments').optional().isArray(),
|
||||||
|
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']),
|
||||||
|
body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(),
|
||||||
|
body('skontoDisabled').optional().isBoolean(),
|
||||||
|
// Inline event snapshot (migration 123). Mirrors quotes — kept
|
||||||
|
// optional because standalone invoices may not have an event yet.
|
||||||
|
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('lineItems').optional({ values: 'falsy' }).isArray(),
|
||||||
|
body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }),
|
||||||
|
body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }),
|
||||||
|
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
// Migration 119: sub-item + details support. Cross-row constraints
|
||||||
|
// (parent must exist, max 1 level deep) are enforced by the service
|
||||||
|
// (validateLineItemHierarchy).
|
||||||
|
body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function mapPayloadToService(body) {
|
||||||
|
const out = {};
|
||||||
|
const map = {
|
||||||
|
customerAccountId: 'customerAccountId',
|
||||||
|
sourceQuoteId: 'sourceQuoteId',
|
||||||
|
eventId: 'eventId',
|
||||||
|
language: 'language', currency: 'currency',
|
||||||
|
issueDate: 'issueDate', dueDate: 'dueDate',
|
||||||
|
scheduledSendAt: 'scheduledSendAt',
|
||||||
|
installmentIndex: 'installmentIndex',
|
||||||
|
installmentTotal: 'installmentTotal',
|
||||||
|
installmentLabel: 'installmentLabel',
|
||||||
|
installmentTrigger: 'installmentTrigger',
|
||||||
|
vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor',
|
||||||
|
ccPdfEmail: 'ccPdfEmail', businessBankAccountId: 'businessBankAccountId',
|
||||||
|
qrFormat: 'qrFormat',
|
||||||
|
eventName: 'eventName',
|
||||||
|
eventDate: 'eventDate',
|
||||||
|
eventTimeStart: 'eventTimeStart',
|
||||||
|
eventTimeEnd: 'eventTimeEnd',
|
||||||
|
paymentTermTemplateId: 'paymentTermTemplateId',
|
||||||
|
paymentNetDaysTemplateId: 'paymentNetDaysTemplateId',
|
||||||
|
paymentTimingTemplateId: 'paymentTimingTemplateId',
|
||||||
|
skontoDisabled: 'skontoDisabled',
|
||||||
|
// Ad-hoc installment plan from the InstallmentsPanel. When the
|
||||||
|
// array has ≥2 entries with percent > 0, createInvoice routes
|
||||||
|
// through spawnInstallmentInvoices (commit #4).
|
||||||
|
installments: 'installments',
|
||||||
|
};
|
||||||
|
for (const [api, svc] of Object.entries(map)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.lineItems)) {
|
||||||
|
out.lineItems = body.lineItems.map((li, idx) => ({
|
||||||
|
position: li.position == null ? idx + 1 : li.position,
|
||||||
|
quantity: li.quantity,
|
||||||
|
description: li.description,
|
||||||
|
unit_price_minor: li.unitPriceMinor,
|
||||||
|
discount_percent: li.discountPercent,
|
||||||
|
// Migration 119 sub-item + details support — same mapping as
|
||||||
|
// quotes so the editor's payload shape is identical for both.
|
||||||
|
parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition),
|
||||||
|
details_text: li.detailsText == null ? null : String(li.detailsText),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- list + read -----------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
[
|
||||||
|
query('status').optional({ values: 'falsy' }).isString(),
|
||||||
|
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']),
|
||||||
|
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const statusFilter = req.query.status
|
||||||
|
? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const { rows, total, page, pageSize } = await invoiceService.listInvoices({
|
||||||
|
filters: {
|
||||||
|
status: statusFilter,
|
||||||
|
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
||||||
|
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
|
||||||
|
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
|
||||||
|
q: req.query.q,
|
||||||
|
},
|
||||||
|
sort: req.query.sort || 'newest',
|
||||||
|
page: req.query.page ? parseInt(req.query.page, 10) : 1,
|
||||||
|
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
|
||||||
|
});
|
||||||
|
return successResponse(res, {
|
||||||
|
invoices: rows.map(transformInvoice),
|
||||||
|
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const data = await invoiceService.getInvoiceById(id);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
// Resolve the effective Skonto percentage so the BillDetail
|
||||||
|
// "Record payment" dialog can render the "Paid with Skonto"
|
||||||
|
// checkbox + auto-fill the discounted amount (migration 126).
|
||||||
|
// Reuses the same resolver the payment-check email path uses so
|
||||||
|
// the two surfaces agree on whether the invoice qualifies.
|
||||||
|
const skontoPercent = await invoiceService.resolveSkontoPercentForInvoice(data.invoice);
|
||||||
|
const invoiceOut = transformInvoice(data.invoice);
|
||||||
|
invoiceOut.skontoPercent = skontoPercent || null;
|
||||||
|
return successResponse(res, {
|
||||||
|
invoice: invoiceOut,
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
payments: data.payments.map(transformPaymentLog),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- create + update -------------------------------------------------
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[body('customerAccountId').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
// createInvoice always returns `{ invoiceIds: number[] }` —
|
||||||
|
// single-installment / standalone case is a one-element array,
|
||||||
|
// multi-installment is N (auto-routed through
|
||||||
|
// spawnInstallmentInvoices). The response surfaces the first
|
||||||
|
// invoice's payload (the one the editor redirects to) plus the
|
||||||
|
// full id list so the editor can show "N invoices created".
|
||||||
|
const { invoiceIds } = await invoiceService.createInvoice(mapPayloadToService(req.body), req.admin.id);
|
||||||
|
const firstId = invoiceIds[0];
|
||||||
|
const data = await invoiceService.getInvoiceById(firstId);
|
||||||
|
return successResponse(res, {
|
||||||
|
invoice: transformInvoice(data.invoice),
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
invoiceIds,
|
||||||
|
}, 201, 'Invoice created');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /import — attach a historical invoice PDF to a customer's
|
||||||
|
// account. Inserts a minimal invoice row whose `imported_pdf_path`
|
||||||
|
// points at the uploaded file. Every PDF endpoint (admin + customer)
|
||||||
|
// short-circuits the renderer when this column is populated, so the
|
||||||
|
// customer downloads the original document untouched.
|
||||||
|
//
|
||||||
|
// Use case: migrating from QuickBooks / Bexio / Xero — the admin
|
||||||
|
// keeps the legal records intact but the customer still sees a
|
||||||
|
// consolidated history in their portal.
|
||||||
|
//
|
||||||
|
// Form fields (multipart/form-data):
|
||||||
|
// pdf file (required, application/pdf, max 10MB)
|
||||||
|
// customerAccountId int (required)
|
||||||
|
// invoiceNumber string (required — admin types the original)
|
||||||
|
// issueDate ISO date (required)
|
||||||
|
// dueDate ISO date (optional, defaults to issueDate)
|
||||||
|
// totalAmountMinor int minor units (required)
|
||||||
|
// currency 3-letter ISO (optional, default profile/CHF)
|
||||||
|
// status 'sent' | 'paid' | 'overdue' (default 'sent')
|
||||||
|
// paidAmountMinor int (optional, for status='paid')
|
||||||
|
// language string (optional, default 'de')
|
||||||
|
router.post(
|
||||||
|
'/import',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
importedInvoiceUpload.single('pdf'),
|
||||||
|
[
|
||||||
|
body('customerAccountId').isInt({ min: 1 }),
|
||||||
|
body('invoiceNumber').isString().isLength({ min: 1, max: 64 }),
|
||||||
|
body('issueDate').isISO8601(),
|
||||||
|
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('totalAmountMinor').isInt({ min: 0 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']),
|
||||||
|
body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'PDF file is required' });
|
||||||
|
|
||||||
|
// Confirm the customer exists + has bills enabled (same gate as
|
||||||
|
// the regular createInvoice).
|
||||||
|
const customer = await db('customer_accounts').where({ id: req.body.customerAccountId }).first();
|
||||||
|
if (!customer) {
|
||||||
|
// Clean up the uploaded file so failed imports don't leave
|
||||||
|
// orphans on disk.
|
||||||
|
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
|
||||||
|
return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
}
|
||||||
|
if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') {
|
||||||
|
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'This customer has bills disabled',
|
||||||
|
code: 'CUSTOMER_FEATURE_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse duplicate invoice numbers — tax compliance requires
|
||||||
|
// uniqueness within the issuer's books.
|
||||||
|
const conflict = await db('invoices').where({ invoice_number: req.body.invoiceNumber }).first();
|
||||||
|
if (conflict) {
|
||||||
|
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Invoice number "${req.body.invoiceNumber}" already exists`,
|
||||||
|
code: 'INVOICE_NUMBER_TAKEN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
|
||||||
|
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
|
||||||
|
const status = req.body.status || 'sent';
|
||||||
|
const issueDate = req.body.issueDate;
|
||||||
|
const dueDate = req.body.dueDate || issueDate;
|
||||||
|
const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase();
|
||||||
|
const language = req.body.language || customer.preferred_language || 'de';
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
invoice_number: req.body.invoiceNumber,
|
||||||
|
customer_account_id: customer.id,
|
||||||
|
source_quote_id: null,
|
||||||
|
event_id: null,
|
||||||
|
language,
|
||||||
|
currency,
|
||||||
|
issue_date: issueDate,
|
||||||
|
due_date: dueDate,
|
||||||
|
installment_index: 0,
|
||||||
|
installment_total: 1,
|
||||||
|
installment_label: null,
|
||||||
|
installment_trigger: null,
|
||||||
|
status,
|
||||||
|
scheduled_send_at: null,
|
||||||
|
sent_at: status !== 'scheduled' ? new Date() : null,
|
||||||
|
net_amount_minor: totalMinor, // imported docs lack a breakdown
|
||||||
|
vat_rate: 0, // VAT info lives in the imported PDF
|
||||||
|
vat_amount_minor: 0,
|
||||||
|
shipping_amount_minor: 0,
|
||||||
|
total_amount_minor: totalMinor,
|
||||||
|
paid_amount_minor: paidMinor,
|
||||||
|
paid_at: status === 'paid' ? new Date() : null,
|
||||||
|
// Store the path RELATIVE to STORAGE_PATH so the value survives
|
||||||
|
// a host migration (Docker volume remount on a new host with a
|
||||||
|
// different absolute path).
|
||||||
|
imported_pdf_path: path.relative(getStoragePath(), req.file.path),
|
||||||
|
created_by_admin_id: req.admin.id,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const inserted = await db('invoices').insert(row).returning('id');
|
||||||
|
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
invoice: transformInvoice(await db('invoices').where({ id: invoiceId }).first()),
|
||||||
|
}, 201, 'Invoice imported');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT — full re-save delegated through createInvoice's helper isn't
|
||||||
|
// straightforward (we keep the existing row). Implementing as a small
|
||||||
|
// inline shim that overrides scalars + replaces line items.
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const existing = await db('invoices').where({ id }).first();
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
// Once an invoice has been sent to the customer it becomes a
|
||||||
|
// legal record under CH/LI/DE/AT tax rules ("Rechnung ist
|
||||||
|
// ausgestellt"). Modifying it in place would break the audit
|
||||||
|
// trail — the correct workflow is to cancel the original +
|
||||||
|
// issue a new one. Only `scheduled` (not yet sent) invoices
|
||||||
|
// remain editable.
|
||||||
|
if (existing.status !== 'scheduled') {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Cannot edit invoice with status '${existing.status}'. Sent invoices are locked — cancel and reissue if changes are needed.`,
|
||||||
|
code: 'INVOICE_LOCKED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = mapPayloadToService(req.body);
|
||||||
|
|
||||||
|
// Recompute totals if line items are present.
|
||||||
|
let updates = { updated_at: new Date() };
|
||||||
|
const map = {
|
||||||
|
language: 'language', currency: 'currency',
|
||||||
|
issueDate: 'issue_date', dueDate: 'due_date',
|
||||||
|
scheduledSendAt: 'scheduled_send_at',
|
||||||
|
installmentIndex: 'installment_index',
|
||||||
|
installmentTotal: 'installment_total',
|
||||||
|
installmentLabel: 'installment_label',
|
||||||
|
installmentTrigger: 'installment_trigger',
|
||||||
|
vatRate: 'vat_rate', shippingAmountMinor: 'shipping_amount_minor',
|
||||||
|
ccPdfEmail: 'cc_pdf_email', businessBankAccountId: 'business_bank_account_id',
|
||||||
|
qrFormat: 'qr_format',
|
||||||
|
// Per-invoice Skonto opt-out (migration 126).
|
||||||
|
skontoDisabled: 'skonto_disabled',
|
||||||
|
// Inline event snapshot (migration 123) — editable as long as
|
||||||
|
// the invoice is still in 'scheduled' status (this route already
|
||||||
|
// gates on that above).
|
||||||
|
eventName: 'event_name',
|
||||||
|
eventDate: 'event_date',
|
||||||
|
eventTimeStart: 'event_time_start',
|
||||||
|
eventTimeEnd: 'event_time_end',
|
||||||
|
};
|
||||||
|
for (const [api, col] of Object.entries(map)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, api)) updates[col] = payload[api];
|
||||||
|
}
|
||||||
|
// Payment-term selection: re-snapshot the template when the admin
|
||||||
|
// changes it. Mirrors createInvoice — once the column is set the
|
||||||
|
// PDF renderer prefers it over the source-quote fallback.
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, 'paymentTermTemplateId')) {
|
||||||
|
const id = parseInt(payload.paymentTermTemplateId, 10);
|
||||||
|
if (id) {
|
||||||
|
const tpl = await db('payment_term_templates').where({ id }).first();
|
||||||
|
if (tpl) {
|
||||||
|
updates.payment_term_template_id = tpl.id;
|
||||||
|
updates.payment_term_snapshot = JSON.stringify({
|
||||||
|
description: tpl.description || null,
|
||||||
|
net_days: tpl.net_days,
|
||||||
|
skonto_percent: tpl.skonto_percent,
|
||||||
|
skonto_within_days: tpl.skonto_within_days,
|
||||||
|
installments: typeof tpl.installments === 'string'
|
||||||
|
? (() => { try { return JSON.parse(tpl.installments); } catch { return null; } })()
|
||||||
|
: tpl.installments || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Explicit clear — admin picked "no template".
|
||||||
|
updates.payment_term_template_id = null;
|
||||||
|
updates.payment_term_snapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 124 — split payment-term picker. When both new FKs are
|
||||||
|
// present, prefer them and re-compose the snapshot from the pair.
|
||||||
|
// The editor sends both together so we don't have to handle the
|
||||||
|
// half-set case; it stays a noop here when only one is supplied.
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, 'paymentNetDaysTemplateId')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(payload, 'paymentTimingTemplateId')
|
||||||
|
) {
|
||||||
|
const netDaysId = parseInt(payload.paymentNetDaysTemplateId, 10);
|
||||||
|
const timingId = parseInt(payload.paymentTimingTemplateId, 10);
|
||||||
|
if (netDaysId && timingId) {
|
||||||
|
const [netDays, timing] = await Promise.all([
|
||||||
|
db('payment_net_days_templates').where({ id: netDaysId }).first(),
|
||||||
|
db('payment_timing_templates').where({ id: timingId }).first(),
|
||||||
|
]);
|
||||||
|
if (netDays && timing) {
|
||||||
|
updates.payment_net_days_template_id = netDays.id;
|
||||||
|
updates.payment_timing_template_id = timing.id;
|
||||||
|
// Clear the legacy FK — the editor is moving off it.
|
||||||
|
updates.payment_term_template_id = null;
|
||||||
|
updates.payment_term_snapshot = JSON.stringify({
|
||||||
|
description: timing.description || netDays.description || null,
|
||||||
|
net_days: netDays.net_days,
|
||||||
|
skonto_percent: netDays.skonto_percent,
|
||||||
|
skonto_within_days: netDays.skonto_within_days,
|
||||||
|
installments: typeof timing.installments === 'string'
|
||||||
|
? (() => { try { return JSON.parse(timing.installments); } catch { return null; } })()
|
||||||
|
: timing.installments || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Explicit clear — admin emptied both.
|
||||||
|
updates.payment_net_days_template_id = null;
|
||||||
|
updates.payment_timing_template_id = null;
|
||||||
|
updates.payment_term_snapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.lineItems)) {
|
||||||
|
// Recompute everything authoritatively. Migration 119 — sub-
|
||||||
|
// items don't roll into net directly; parent totals auto-
|
||||||
|
// resolve from priced sub-items via resolveParentTotalsFromSubItems
|
||||||
|
// (shared helper in quoteService._internal).
|
||||||
|
const items = payload.lineItems.map((li, idx) => {
|
||||||
|
const qty = Number(li.quantity || 1);
|
||||||
|
const unit = parseInt(li.unit_price_minor, 10) || 0;
|
||||||
|
const disc = Number(li.discount_percent || 0);
|
||||||
|
const lineTotal = Math.round(Math.round(qty * unit) * (1 - disc / 100));
|
||||||
|
const isSubItem = li.parent_position != null && li.parent_position !== '';
|
||||||
|
return {
|
||||||
|
position: parseInt(li.position, 10) || (idx + 1),
|
||||||
|
quantity: qty,
|
||||||
|
description: String(li.description || ''),
|
||||||
|
unit_price_minor: unit,
|
||||||
|
discount_percent: disc,
|
||||||
|
line_total_minor: lineTotal,
|
||||||
|
parent_position: isSubItem ? parseInt(li.parent_position, 10) : null,
|
||||||
|
details_text: li.details_text || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const { resolveParentTotalsFromSubItems } = require('../services/quoteService')._internal;
|
||||||
|
resolveParentTotalsFromSubItems(items);
|
||||||
|
let net = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.parent_position == null) net += parseInt(it.line_total_minor, 10) || 0;
|
||||||
|
}
|
||||||
|
const vatRate = Number(payload.vatRate ?? existing.vat_rate ?? 0);
|
||||||
|
const vatAmount = Math.round(net * vatRate / 100);
|
||||||
|
const shipping = parseInt(payload.shippingAmountMinor ?? existing.shipping_amount_minor ?? 0, 10);
|
||||||
|
updates.net_amount_minor = net;
|
||||||
|
updates.vat_amount_minor = vatAmount;
|
||||||
|
updates.vat_rate = vatRate;
|
||||||
|
updates.shipping_amount_minor = shipping;
|
||||||
|
updates.total_amount_minor = net + vatAmount + shipping;
|
||||||
|
|
||||||
|
const quoteService = require('../services/quoteService');
|
||||||
|
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx('invoice_line_items').where({ invoice_id: id }).del();
|
||||||
|
if (items.length > 0) {
|
||||||
|
validateLineItemHierarchy(items);
|
||||||
|
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', id, items);
|
||||||
|
}
|
||||||
|
await trx('invoices').where({ id }).update(updates);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await db('invoices').where({ id }).update(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await invoiceService.getInvoiceById(id);
|
||||||
|
return successResponse(res, {
|
||||||
|
invoice: transformInvoice(data.invoice),
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
}, 200, 'Invoice updated');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- send / pay / remind / cancel ------------------------------------
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/send',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id);
|
||||||
|
return successResponse(res, { sent: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/mark-paid',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('amountMinor').isInt({ min: 1 }),
|
||||||
|
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('paymentMethod').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||||
|
body('reference').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('notes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('skontoApplied').optional().isBoolean(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.markPaid(parseInt(req.params.id, 10), {
|
||||||
|
amountMinor: req.body.amountMinor,
|
||||||
|
paidAt: req.body.paidAt,
|
||||||
|
paymentMethod: req.body.paymentMethod,
|
||||||
|
reference: req.body.reference,
|
||||||
|
notes: req.body.notes,
|
||||||
|
skontoApplied: req.body.skontoApplied,
|
||||||
|
}, req.admin.id);
|
||||||
|
return successResponse(res, result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/send-reminder',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('level').optional({ values: 'falsy' }).isInt({ min: 1, max: 2 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.sendReminder(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
req.body.level || null,
|
||||||
|
req.admin.id
|
||||||
|
);
|
||||||
|
return successResponse(res, result, 200, 'Reminder sent');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test the admin payment-check email manually — bypasses the 24h
|
||||||
|
// throttle so the admin can verify the full flow (email → token
|
||||||
|
// page → action recorded) without waiting for the invoice to age
|
||||||
|
// past its reminder threshold. Only operates on sent/overdue
|
||||||
|
// invoices (same gate as the scheduled path).
|
||||||
|
router.post(
|
||||||
|
'/:id/test-payment-check',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.queuePaymentCheckEmail(
|
||||||
|
parseInt(req.params.id, 10),
|
||||||
|
{ skipThrottle: true }
|
||||||
|
);
|
||||||
|
if (!result.sent) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Payment-check email not sent: ${result.reason}`,
|
||||||
|
code: 'PAYMENT_CHECK_NOT_SENT',
|
||||||
|
reason: result.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return successResponse(res, result, 200, 'Test payment-check email queued');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel + reissue — atomically cancels the existing invoice and
|
||||||
|
// creates a fresh scheduled duplicate with a new sequential number,
|
||||||
|
// linked via replaces_invoice_id (migration 114). The PDF renderer
|
||||||
|
// stamps "Bezug: Ersetzt Rechnung R-XXXX vom DATE" on the new
|
||||||
|
// invoice so the customer + auditors can trace the chain.
|
||||||
|
router.post(
|
||||||
|
'/:id/reissue',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.reissueInvoice(parseInt(req.params.id, 10), req.admin.id);
|
||||||
|
return successResponse(res, result, 201, 'Invoice reissued');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release a pending_delivery invoice — photographer has confirmed
|
||||||
|
// delivery and wants the final installment to fire now.
|
||||||
|
router.post(
|
||||||
|
'/:id/release-for-delivery',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.releaseForDelivery(parseInt(req.params.id, 10), req.admin.id);
|
||||||
|
return successResponse(res, result, 200, 'Delivery invoice released');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/cancel',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
// Service returns { cancelled, stornoId } — pass through so the
|
||||||
|
// frontend can show "Storno S-XXXX wurde erzeugt" feedback
|
||||||
|
// when the invoice was already issued (vs. silent soft-cancel
|
||||||
|
// on drafts).
|
||||||
|
const result = await invoiceService.cancelInvoice(parseInt(req.params.id, 10), req.admin.id);
|
||||||
|
return successResponse(res, result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- PDF -------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/pdf',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const buf = await invoiceService.renderInvoicePdfBuffer(id);
|
||||||
|
// Build a useful filename: `<invoiceNumber>_<customerName>.pdf`.
|
||||||
|
// The number + customer come from a small joined fetch; we
|
||||||
|
// already loaded everything inside renderInvoicePdfBuffer, but
|
||||||
|
// re-fetching here keeps the route a thin shim over the
|
||||||
|
// service rather than reaching inside its internals.
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const inv = await db('invoices').where({ id }).first();
|
||||||
|
const customer = inv ? await db('customer_accounts').where({ id: inv.customer_account_id }).first() : null;
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: inv?.invoice_number,
|
||||||
|
customer,
|
||||||
|
fallback: `invoice-${id}`,
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/preview',
|
||||||
|
requirePermission('bills.manage'),
|
||||||
|
INVOICE_BODY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const payload = mapPayloadToService(req.body);
|
||||||
|
const buf = await invoiceService.renderInvoicePdfFromPayload(payload);
|
||||||
|
// Preview is unsaved — there's no invoice_number yet. Look up
|
||||||
|
// the customer so the filename still reflects who the invoice
|
||||||
|
// is for; the number segment falls back to "invoice-preview".
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const customer = payload.customerAccountId
|
||||||
|
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||||
|
: null;
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: null,
|
||||||
|
customer,
|
||||||
|
fallback: 'invoice-preview',
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/payment-log',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const data = await invoiceService.getInvoiceById(parseInt(req.params.id, 10));
|
||||||
|
if (!data) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
return successResponse(res, { payments: data.payments.map(transformPaymentLog) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Quotes Routes
|
||||||
|
*
|
||||||
|
* Endpoint mounted at /api/admin/quotes. Surface:
|
||||||
|
* GET / list (filter + sort + paginate)
|
||||||
|
* POST / create (status=draft)
|
||||||
|
* GET /:id detail
|
||||||
|
* PUT /:id update (line items + scalars)
|
||||||
|
* POST /:id/send render PDF + queue email
|
||||||
|
* POST /:id/duplicate clone as new draft
|
||||||
|
* POST /:id/convert convert accepted quote → event
|
||||||
|
* GET /:id/pdf preview / download persisted PDF
|
||||||
|
* POST /preview render PDF from unsaved payload
|
||||||
|
* GET /presets/line-items
|
||||||
|
* POST /presets/line-items
|
||||||
|
* PUT /presets/line-items/:id
|
||||||
|
* DELETE /presets/line-items/:id
|
||||||
|
* GET /presets/payment-terms
|
||||||
|
* POST /presets/payment-terms
|
||||||
|
* PUT /presets/payment-terms/:id
|
||||||
|
* DELETE /presets/payment-terms/:id
|
||||||
|
*
|
||||||
|
* Permissions: `quotes.view` for reads, `quotes.manage` for writes.
|
||||||
|
* The global `quotes` feature flag is checked at the route layer so a
|
||||||
|
* disabled installation returns 403 cleanly without the route bodies
|
||||||
|
* ever running.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param, query } = require('express-validator');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const quoteService = require('../services/quoteService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ----- feature flag gate (admin global) -------------------------------
|
||||||
|
async function requireQuotesFlag(req, res, next) {
|
||||||
|
try {
|
||||||
|
const row = await db('feature_flags').where({ key: 'quotes' }).first();
|
||||||
|
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
if (!enabled) {
|
||||||
|
return res.status(403).json({ error: 'Quotes feature is disabled', code: 'QUOTES_DISABLED' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use(requireQuotesFlag);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Transforms (snake_case DB → camelCase API)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function transformQuote(q) {
|
||||||
|
if (!q) return null;
|
||||||
|
return {
|
||||||
|
id: q.id,
|
||||||
|
quoteNumber: q.quote_number,
|
||||||
|
customerAccountId: q.customer_account_id,
|
||||||
|
customer: {
|
||||||
|
email: q.customer_email,
|
||||||
|
displayName: q.customer_display_name,
|
||||||
|
firstName: q.customer_first_name,
|
||||||
|
lastName: q.customer_last_name,
|
||||||
|
companyName: q.customer_company_name,
|
||||||
|
// Passive customers (admin-only, no portal access) flagged
|
||||||
|
// by null password_hash. Hash itself is dropped here.
|
||||||
|
isPassive: q.customer_password_hash == null,
|
||||||
|
},
|
||||||
|
status: q.status,
|
||||||
|
// Migration 140 — cross-document lineage UUID. Lets the frontend
|
||||||
|
// call /api/admin/deals/:uuid/documents in one shot to render the
|
||||||
|
// full lineage (quote + contract + N invoices + Storni).
|
||||||
|
dealUuid: q.deal_uuid || null,
|
||||||
|
language: q.language,
|
||||||
|
currency: q.currency,
|
||||||
|
issueDate: q.issue_date,
|
||||||
|
validUntil: q.valid_until,
|
||||||
|
eventName: q.event_name,
|
||||||
|
eventDate: q.event_date,
|
||||||
|
eventTimeStart: q.event_time_start,
|
||||||
|
eventTimeEnd: q.event_time_end,
|
||||||
|
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
|
||||||
|
paymentTermTemplateId: q.payment_term_template_id,
|
||||||
|
// Split payment-term picker (migration 124).
|
||||||
|
paymentNetDaysTemplateId: q.payment_net_days_template_id || null,
|
||||||
|
paymentTimingTemplateId: q.payment_timing_template_id || null,
|
||||||
|
netAmountMinor: q.net_amount_minor,
|
||||||
|
vatRate: q.vat_rate == null ? null : Number(q.vat_rate),
|
||||||
|
vatAmountMinor: q.vat_amount_minor,
|
||||||
|
shippingAmountMinor: q.shipping_amount_minor,
|
||||||
|
totalAmountMinor: q.total_amount_minor,
|
||||||
|
introText: q.intro_text,
|
||||||
|
outroText: q.outro_text,
|
||||||
|
internalNotes: q.internal_notes,
|
||||||
|
ccPdfEmail: q.cc_pdf_email,
|
||||||
|
sentAt: q.sent_at,
|
||||||
|
respondedAt: q.responded_at,
|
||||||
|
responseLockedAt: q.response_locked_at,
|
||||||
|
acceptedAt: q.accepted_at,
|
||||||
|
declinedAt: q.declined_at,
|
||||||
|
convertedEventId: q.converted_event_id,
|
||||||
|
// Migration 130 lineage. Null until quoteService.createFromQuote
|
||||||
|
// sets it. Surfaced so QuoteDetailPage can render a "Linked
|
||||||
|
// contract" badge alongside the existing resulting-invoices list.
|
||||||
|
// contract_number comes from the conv_contract JOIN — falls back
|
||||||
|
// to null when the converted contract has been deleted (FK is
|
||||||
|
// ON DELETE SET NULL).
|
||||||
|
convertedContractId: q.converted_contract_id || null,
|
||||||
|
convertedContractNumber: q.converted_contract_number || null,
|
||||||
|
pdfPath: q.pdf_path,
|
||||||
|
businessBankAccountId: q.business_bank_account_id,
|
||||||
|
createdAt: q.created_at,
|
||||||
|
updatedAt: q.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLineItem(li) {
|
||||||
|
return {
|
||||||
|
id: li.id,
|
||||||
|
position: li.position,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
description: li.description,
|
||||||
|
unitPriceMinor: li.unit_price_minor,
|
||||||
|
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
|
||||||
|
lineTotalMinor: li.line_total_minor,
|
||||||
|
// Hierarchy (migration 119). `parentPosition` is what the editor
|
||||||
|
// uses to thread sub-items in unsaved drafts; on existing rows
|
||||||
|
// we hydrate it from the actual parent's position via a join in
|
||||||
|
// getQuoteById (see service). NULL = top-level item.
|
||||||
|
parentLineItemId: li.parent_line_item_id || null,
|
||||||
|
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
|
||||||
|
detailsText: li.details_text || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPaymentTermTemplate(t) {
|
||||||
|
if (!t) return null;
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
netDays: t.net_days,
|
||||||
|
skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent),
|
||||||
|
skontoWithinDays: t.skonto_within_days,
|
||||||
|
installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments,
|
||||||
|
isSystem: t.is_system === 1 || t.is_system === true,
|
||||||
|
isActive: t.is_active === 1 || t.is_active === true,
|
||||||
|
displayOrder: t.display_order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split payment-term templates (migration 124). Two transforms because
|
||||||
|
// the rows have different shapes — net-days carries Skonto, timing
|
||||||
|
// carries the installments array.
|
||||||
|
function transformPaymentNetDaysTemplate(t) {
|
||||||
|
if (!t) return null;
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
netDays: t.net_days,
|
||||||
|
skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent),
|
||||||
|
skontoWithinDays: t.skonto_within_days,
|
||||||
|
isSystem: t.is_system === 1 || t.is_system === true,
|
||||||
|
isActive: t.is_active === 1 || t.is_active === true,
|
||||||
|
displayOrder: t.display_order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPaymentTimingTemplate(t) {
|
||||||
|
if (!t) return null;
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments,
|
||||||
|
isSystem: t.is_system === 1 || t.is_system === true,
|
||||||
|
isActive: t.is_active === 1 || t.is_active === true,
|
||||||
|
displayOrder: t.display_order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLineItemPreset(p) {
|
||||||
|
if (!p) return null;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
unitPriceMinor: p.unit_price_minor,
|
||||||
|
currency: p.currency,
|
||||||
|
quantityDefault: Number(p.quantity_default),
|
||||||
|
displayOrder: p.display_order,
|
||||||
|
isActive: p.is_active === 1 || p.is_active === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- payload conversion helpers ------------------------------------
|
||||||
|
|
||||||
|
function mapPayloadToService(body) {
|
||||||
|
const out = {};
|
||||||
|
const map = {
|
||||||
|
customerAccountId: 'customerAccountId',
|
||||||
|
language: 'language', currency: 'currency',
|
||||||
|
issueDate: 'issueDate', validUntil: 'validUntil',
|
||||||
|
eventName: 'eventName', eventDate: 'eventDate',
|
||||||
|
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
|
||||||
|
expectedDurationHours: 'expectedDurationHours',
|
||||||
|
paymentTermTemplateId: 'paymentTermTemplateId',
|
||||||
|
paymentNetDaysTemplateId: 'paymentNetDaysTemplateId',
|
||||||
|
paymentTimingTemplateId: 'paymentTimingTemplateId',
|
||||||
|
// Ad-hoc installments override (commit #6). Stored on quotes
|
||||||
|
// as payment_term_installments_override via migration 142.
|
||||||
|
installments: 'installments',
|
||||||
|
vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor',
|
||||||
|
introText: 'introText', outroText: 'outroText',
|
||||||
|
internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail',
|
||||||
|
businessBankAccountId: 'businessBankAccountId',
|
||||||
|
};
|
||||||
|
for (const [api, svc] of Object.entries(map)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.lineItems)) {
|
||||||
|
out.lineItems = body.lineItems.map((li, idx) => ({
|
||||||
|
position: li.position == null ? idx + 1 : li.position,
|
||||||
|
quantity: li.quantity,
|
||||||
|
description: li.description,
|
||||||
|
unit_price_minor: li.unitPriceMinor,
|
||||||
|
discount_percent: li.discountPercent,
|
||||||
|
// Migration 119 — sub-item + details support. parentPosition
|
||||||
|
// refers to another item's position in the same payload; the
|
||||||
|
// service resolves it to parent_line_item_id after inserting
|
||||||
|
// the parents.
|
||||||
|
parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition),
|
||||||
|
details_text: li.detailsText == null ? null : String(li.detailsText),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// List + read
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
[
|
||||||
|
query('status').optional({ values: 'falsy' }).isString(),
|
||||||
|
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']),
|
||||||
|
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const statusFilter = req.query.status
|
||||||
|
? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const { rows, total, page, pageSize } = await quoteService.listQuotes({
|
||||||
|
filters: {
|
||||||
|
status: statusFilter,
|
||||||
|
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
||||||
|
from: req.query.from, to: req.query.to, q: req.query.q,
|
||||||
|
},
|
||||||
|
sort: req.query.sort || 'newest',
|
||||||
|
page: req.query.page ? parseInt(req.query.page, 10) : 1,
|
||||||
|
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
|
||||||
|
});
|
||||||
|
return successResponse(res, {
|
||||||
|
quotes: rows.map(transformQuote),
|
||||||
|
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const data = await quoteService.getQuoteById(id);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
return successResponse(res, {
|
||||||
|
quote: transformQuote(data.quote),
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Create + update
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const QUOTE_BODY_VALIDATORS = [
|
||||||
|
body('customerAccountId').isInt({ min: 1 }).withMessage('Customer is required'),
|
||||||
|
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('validUntil').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }),
|
||||||
|
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
// Ad-hoc installments override (commit #6 of the deal_uuid PR).
|
||||||
|
// Each row carries { label, percent, trigger, offset_days }; the
|
||||||
|
// service validates internal consistency (percents sum to 100).
|
||||||
|
body('installments').optional().isArray(),
|
||||||
|
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||||
|
body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']),
|
||||||
|
body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(),
|
||||||
|
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('lineItems').optional({ values: 'falsy' }).isArray(),
|
||||||
|
body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }),
|
||||||
|
body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }),
|
||||||
|
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
// Migration 119: sub-item + details support. Cross-row constraints
|
||||||
|
// (parent must exist, max 1 level deep) are enforced by the service
|
||||||
|
// (validateLineItemHierarchy); these per-field validators just keep
|
||||||
|
// bad data from reaching it.
|
||||||
|
body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
QUOTE_BODY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = await quoteService.createQuote(mapPayloadToService(req.body), req.admin.id);
|
||||||
|
const data = await quoteService.getQuoteById(id);
|
||||||
|
return successResponse(res, {
|
||||||
|
quote: transformQuote(data.quote),
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
}, 201, 'Quote created');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
// PUT accepts partial updates. We declare the same fields as POST but
|
||||||
|
// every chain begins with `.optional({ values: 'falsy' })` so missing fields don't fail
|
||||||
|
// validation. Doing `.map(v => v.optional)` (without invoking it) was
|
||||||
|
// a bug that registered method references as middleware.
|
||||||
|
[
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('validUntil').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
|
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }),
|
||||||
|
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
body('lineItems').optional({ values: 'falsy' }).isArray(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
await quoteService.updateQuote(id, mapPayloadToService(req.body), req.admin.id);
|
||||||
|
const data = await quoteService.getQuoteById(id);
|
||||||
|
return successResponse(res, {
|
||||||
|
quote: transformQuote(data.quote),
|
||||||
|
lineItems: data.lineItems.map(transformLineItem),
|
||||||
|
}, 200, 'Quote updated');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Send / duplicate / convert
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/send',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await quoteService.sendQuote(id, req.admin.id);
|
||||||
|
return successResponse(res, { sent: true, token: result.token }, 200, 'Quote sent');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/duplicate',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const newId = await quoteService.duplicateQuote(parseInt(req.params.id, 10), req.admin.id);
|
||||||
|
return successResponse(res, { id: newId }, 201, 'Quote duplicated');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin "accept on behalf of customer" — flips the quote straight
|
||||||
|
// to `accepted` without going through the public token + response
|
||||||
|
// window. For phone-call workflows where the customer verbally
|
||||||
|
// agrees and the admin wants to immediately convert.
|
||||||
|
router.post(
|
||||||
|
'/:id/accept',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await quoteService.adminAcceptQuote(id, req.admin.id);
|
||||||
|
return successResponse(res, result, 200, 'Quote accepted');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/convert',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await quoteService.convertToEvent(id, req.admin.id);
|
||||||
|
return successResponse(res, result, 200, result.alreadyConverted ? 'Already converted' : 'Quote converted');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert directly to invoice(s) — no event, no gallery. Used for
|
||||||
|
// engagements like consulting / equipment hire where there's no photo
|
||||||
|
// deliverable to ship.
|
||||||
|
router.post(
|
||||||
|
'/:id/convert-to-invoice',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await quoteService.convertToInvoiceOnly(id, req.admin.id);
|
||||||
|
return successResponse(res, result, 200, 'Invoices created from quote');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to a draft contract — the new middle step between accepted
|
||||||
|
// quote and event/invoice generation. The contracts feature flag is
|
||||||
|
// checked in contractService (it pulls the same db('feature_flags')
|
||||||
|
// row that the adminContracts router gates on); declining at the route
|
||||||
|
// layer here would force admins to flip TWO flags to use the workflow.
|
||||||
|
router.post(
|
||||||
|
'/:id/convert-to-contract',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
// Lazy require to keep the route file dep-light + avoid the
|
||||||
|
// quoteService ↔ contractService cycle bleeding through.
|
||||||
|
const contractService = require('../services/contractService');
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = await contractService.createFromQuote(id, req.admin.id);
|
||||||
|
return successResponse(res, result, 200,
|
||||||
|
result.alreadyConverted ? 'Already linked to a contract' : 'Contract drafted from quote');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PDF — preview (unsaved payload) + download (persisted)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/pdf',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const buf = await quoteService.renderQuotePdfBuffer(id);
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const quote = await db('quotes').where({ id }).first();
|
||||||
|
const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null;
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: quote?.quote_number,
|
||||||
|
customer,
|
||||||
|
fallback: `quote-${id}`,
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/preview',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
QUOTE_BODY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const payload = mapPayloadToService(req.body);
|
||||||
|
const buf = await quoteService.renderQuotePdfFromPayload(payload);
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const customer = payload.customerAccountId
|
||||||
|
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||||
|
: null;
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: null,
|
||||||
|
customer,
|
||||||
|
fallback: 'quote-preview',
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Presets — line items
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/presets/line-items',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const rows = await quoteService.listLineItemPresets();
|
||||||
|
return successResponse(res, { presets: rows.map(transformLineItemPreset) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/presets/line-items',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[
|
||||||
|
body('name').isString().isLength({ min: 1, max: 128 }),
|
||||||
|
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
|
||||||
|
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
|
||||||
|
body('quantityDefault').optional({ values: 'falsy' }).isFloat({ min: 0 }),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const row = await quoteService.createLineItemPreset({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
unit_price_minor: req.body.unitPriceMinor,
|
||||||
|
currency: req.body.currency,
|
||||||
|
quantity_default: req.body.quantityDefault,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
});
|
||||||
|
return successResponse(res, { preset: transformLineItemPreset(row) }, 201);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/presets/line-items/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const row = await quoteService.updateLineItemPreset(id, {
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
unit_price_minor: req.body.unitPriceMinor,
|
||||||
|
currency: req.body.currency,
|
||||||
|
quantity_default: req.body.quantityDefault,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
is_active: req.body.isActive,
|
||||||
|
});
|
||||||
|
return successResponse(res, { preset: transformLineItemPreset(row) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/presets/line-items/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await quoteService.deleteLineItemPreset(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { deleted: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Presets — payment terms
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/presets/payment-terms',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const rows = await quoteService.listPaymentTermTemplates();
|
||||||
|
return successResponse(res, { templates: rows.map(transformPaymentTermTemplate) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/presets/payment-terms',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[
|
||||||
|
body('name').isString().isLength({ min: 1, max: 128 }),
|
||||||
|
body('installments').isArray({ min: 1 }),
|
||||||
|
body('netDays').optional({ values: 'falsy' }).isInt({ min: 1, max: 365 }),
|
||||||
|
body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }),
|
||||||
|
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const row = await quoteService.createPaymentTermTemplate({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
net_days: req.body.netDays,
|
||||||
|
skonto_percent: req.body.skontoPercent,
|
||||||
|
skonto_within_days: req.body.skontoWithinDays,
|
||||||
|
installments: req.body.installments,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentTermTemplate(row) }, 201);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/presets/payment-terms/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const row = await quoteService.updatePaymentTermTemplate(id, {
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
net_days: req.body.netDays,
|
||||||
|
skonto_percent: req.body.skontoPercent,
|
||||||
|
skonto_within_days: req.body.skontoWithinDays,
|
||||||
|
installments: req.body.installments,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
is_active: req.body.isActive,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentTermTemplate(row) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/presets/payment-terms/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await quoteService.deletePaymentTermTemplate(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { deleted: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Presets — payment net-days (migration 124, half of the split)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/presets/payment-net-days',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const rows = await quoteService.listPaymentNetDaysTemplates();
|
||||||
|
return successResponse(res, { templates: rows.map(transformPaymentNetDaysTemplate) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/presets/payment-net-days',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[
|
||||||
|
body('name').isString().isLength({ min: 1, max: 128 }),
|
||||||
|
// net_days = 0 is "Sofort fällig" — valid.
|
||||||
|
body('netDays').isInt({ min: 0, max: 365 }),
|
||||||
|
body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
|
||||||
|
body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }),
|
||||||
|
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const row = await quoteService.createPaymentNetDaysTemplate({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
net_days: req.body.netDays,
|
||||||
|
skonto_percent: req.body.skontoPercent,
|
||||||
|
skonto_within_days: req.body.skontoWithinDays,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentNetDaysTemplate(row) }, 201);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/presets/payment-net-days/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const row = await quoteService.updatePaymentNetDaysTemplate(id, {
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
net_days: req.body.netDays,
|
||||||
|
skonto_percent: req.body.skontoPercent,
|
||||||
|
skonto_within_days: req.body.skontoWithinDays,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
is_active: req.body.isActive,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentNetDaysTemplate(row) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/presets/payment-net-days/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await quoteService.deletePaymentNetDaysTemplate(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { deleted: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Presets — payment timing (migration 124, other half of the split)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/presets/payment-timing',
|
||||||
|
requirePermission('quotes.view'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
const rows = await quoteService.listPaymentTimingTemplates();
|
||||||
|
return successResponse(res, { templates: rows.map(transformPaymentTimingTemplate) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/presets/payment-timing',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[
|
||||||
|
body('name').isString().isLength({ min: 1, max: 128 }),
|
||||||
|
body('installments').isArray({ min: 1 }),
|
||||||
|
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
|
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const row = await quoteService.createPaymentTimingTemplate({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
installments: req.body.installments,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentTimingTemplate(row) }, 201);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/presets/payment-timing/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const row = await quoteService.updatePaymentTimingTemplate(id, {
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
installments: req.body.installments,
|
||||||
|
display_order: req.body.displayOrder,
|
||||||
|
is_active: req.body.isActive,
|
||||||
|
});
|
||||||
|
return successResponse(res, { template: transformPaymentTimingTemplate(row) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/presets/payment-timing/:id',
|
||||||
|
requirePermission('quotes.manage'),
|
||||||
|
[param('id').isInt({ min: 1 })],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
await quoteService.deletePaymentTimingTemplate(parseInt(req.params.id, 10));
|
||||||
|
return successResponse(res, { deleted: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -94,11 +94,24 @@ const faviconUpload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all settings
|
// Get all settings, or a subset when ?keys=k1,k2,… is supplied.
|
||||||
|
// Many caller pages only need a handful of keys (e.g. ReminderTemplates
|
||||||
|
// reads 2 of the ~100 rows). The keys filter is allowlist-bounded by
|
||||||
|
// what's stored, so passing unknown keys just returns them as `null`
|
||||||
|
// — no enumeration risk beyond what GET / returned already.
|
||||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await db('app_settings').select('*');
|
const keysParam = typeof req.query.keys === 'string' ? req.query.keys : null;
|
||||||
|
const keysFilter = keysParam
|
||||||
|
? keysParam.split(',').map((k) => k.trim()).filter(Boolean).slice(0, 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const query = db('app_settings').select('*');
|
||||||
|
if (keysFilter && keysFilter.length > 0) {
|
||||||
|
query.whereIn('setting_key', keysFilter);
|
||||||
|
}
|
||||||
|
const settings = await query;
|
||||||
|
|
||||||
// Convert to object format
|
// Convert to object format
|
||||||
const settingsObject = {};
|
const settingsObject = {};
|
||||||
settings.forEach(setting => {
|
settings.forEach(setting => {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Tax Report Routes
|
||||||
|
*
|
||||||
|
* Mounted at /api/admin/tax-report. Three endpoints with the same
|
||||||
|
* query-string contract (from / to / currency / locale):
|
||||||
|
*
|
||||||
|
* GET / → JSON: { rows, totalsByVatRate, grandTotal*, ... }
|
||||||
|
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
|
||||||
|
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
|
||||||
|
*
|
||||||
|
* Reuses the existing `bills` feature flag + `bills.view` permission.
|
||||||
|
* Tax data is just a different lens on invoice data — admins who can
|
||||||
|
* read invoices can read the tax report; no new RBAC surface needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { query } = require('express-validator');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const taxReportService = require('../services/taxReportService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// The tax report has its own dedicated flag (taxReport) — independent
|
||||||
|
// from `bills` so admins can leave it off until they actually need to
|
||||||
|
// run the export. The frontend mirrors the dependency rule (bills off
|
||||||
|
// → taxReport off) but we re-check both server-side for defence in
|
||||||
|
// depth.
|
||||||
|
async function requireTaxReportFlag(req, res, next) {
|
||||||
|
try {
|
||||||
|
const rows = await db('feature_flags').whereIn('key', ['bills', 'taxReport']).select('key', 'value');
|
||||||
|
const isOn = (row) => row && (row.value === true || row.value === 1 || row.value === '1');
|
||||||
|
const bills = isOn(rows.find((r) => r.key === 'bills'));
|
||||||
|
const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
|
||||||
|
if (!bills) {
|
||||||
|
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
|
||||||
|
}
|
||||||
|
if (!taxReport) {
|
||||||
|
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use(requireTaxReportFlag);
|
||||||
|
|
||||||
|
// Shared validators for from/to/currency. ISO date (YYYY-MM-DD) and
|
||||||
|
// ISO 4217 alpha-3 currency are enforced — anything else is rejected
|
||||||
|
// before the service layer to keep error messages crisp.
|
||||||
|
const QUERY_VALIDATORS = [
|
||||||
|
query('from').exists().withMessage('from is required')
|
||||||
|
.matches(/^\d{4}-\d{2}-\d{2}$/).withMessage('from must be YYYY-MM-DD'),
|
||||||
|
query('to').exists().withMessage('to is required')
|
||||||
|
.matches(/^\d{4}-\d{2}-\d{2}$/).withMessage('to must be YYYY-MM-DD'),
|
||||||
|
query('currency').exists().withMessage('currency is required')
|
||||||
|
.matches(/^[A-Za-z]{3}$/).withMessage('currency must be an ISO 4217 alpha-3 code'),
|
||||||
|
query('locale').optional({ values: 'falsy' })
|
||||||
|
.isIn(['en', 'de', 'fr', 'nl', 'pt', 'ru'])
|
||||||
|
.withMessage('locale must be one of en/de/fr/nl/pt/ru'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseParams(req) {
|
||||||
|
return {
|
||||||
|
from: req.query.from,
|
||||||
|
to: req.query.to,
|
||||||
|
currency: String(req.query.currency || '').toUpperCase(),
|
||||||
|
locale: req.query.locale || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON ------------------------------------------------------------
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
QUERY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const report = await taxReportService.getTaxReport(parseParams(req));
|
||||||
|
return successResponse(res, { report });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- PDF -------------------------------------------------------------
|
||||||
|
router.get(
|
||||||
|
'/pdf',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
QUERY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const params = parseParams(req);
|
||||||
|
const buffer = await taxReportService.renderTaxReportPdf(params);
|
||||||
|
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`;
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.set('Content-Length', String(buffer.length));
|
||||||
|
return res.end(buffer);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- CSV -------------------------------------------------------------
|
||||||
|
router.get(
|
||||||
|
'/csv',
|
||||||
|
requirePermission('bills.view'),
|
||||||
|
QUERY_VALIDATORS,
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const params = parseParams(req);
|
||||||
|
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv(params);
|
||||||
|
res.set('Content-Type', contentType);
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
// BOM for Excel UTF-8 detection — without it Excel on Windows
|
||||||
|
// mis-decodes Umlauts/special chars. Three-byte EF BB BF prefix.
|
||||||
|
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
||||||
|
const body = Buffer.concat([bom, Buffer.from(content, 'utf8')]);
|
||||||
|
res.set('Content-Length', String(body.length));
|
||||||
|
return res.end(body);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -368,4 +368,346 @@ router.post('/profile/password', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- quotes (customer-facing read-only) ------------------------------
|
||||||
|
// Lists quotes belonging to the logged-in customer. Scoped strictly to
|
||||||
|
// the customer's own customer_account_id so a stale or stolen token can
|
||||||
|
// never see another customer's quotes. Returns the same shape the admin
|
||||||
|
// list does, minus fields that are admin-only (internal_notes, pdf_path,
|
||||||
|
// created_by_admin_id). Disabled when the customer has `feature_quotes`
|
||||||
|
// off OR the global `quotes` flag is off — the frontend's RequireFeature
|
||||||
|
// already hides the sidebar entry, but we belt-and-braces it here so a
|
||||||
|
// direct API hit gets a 403 instead of leaking rows.
|
||||||
|
router.get('/quotes', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
// Customer-feature gate. is_active is enforced by customerAuth.
|
||||||
|
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||||
|
if (!customer || customer.feature_quotes === false || customer.feature_quotes === 0) {
|
||||||
|
return res.status(403).json({ error: 'Quotes are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
|
||||||
|
}
|
||||||
|
const rows = await dbi('quotes')
|
||||||
|
.where({ customer_account_id: req.customer.id })
|
||||||
|
// Hide drafts — they're admin scratch work; nothing has been
|
||||||
|
// sent to the customer yet. Mirrors the invoice list above
|
||||||
|
// which suppresses 'scheduled' + 'cancelled' for the same
|
||||||
|
// reason. Customers should only see quotes the admin has
|
||||||
|
// actually issued (sent / accepted / declined / expired /
|
||||||
|
// converted).
|
||||||
|
.whereNotIn('status', ['draft'])
|
||||||
|
.orderBy('issue_date', 'desc')
|
||||||
|
.orderBy('id', 'desc')
|
||||||
|
.select(
|
||||||
|
'id', 'quote_number', 'status', 'currency',
|
||||||
|
'issue_date', 'valid_until', 'event_name', 'event_date',
|
||||||
|
'net_amount_minor', 'vat_rate', 'vat_amount_minor',
|
||||||
|
'shipping_amount_minor', 'total_amount_minor',
|
||||||
|
'intro_text', 'outro_text',
|
||||||
|
'sent_at', 'responded_at', 'response_locked_at',
|
||||||
|
'accepted_at', 'declined_at',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up the active accept/decline token for each non-locked
|
||||||
|
// quote so the customer dashboard can deep-link back into the
|
||||||
|
// public response page when the admin already sent it. We avoid
|
||||||
|
// re-issuing tokens here — the dashboard is for review, not
|
||||||
|
// re-sending.
|
||||||
|
const tokensByQuote = new Map();
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const tokens = await dbi('quote_action_tokens')
|
||||||
|
.whereIn('quote_id', rows.map((r) => r.id))
|
||||||
|
.whereNull('used_at')
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.select('quote_id', 'token');
|
||||||
|
for (const t of tokens) tokensByQuote.set(t.quote_id, t.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
quotes: rows.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
quoteNumber: q.quote_number,
|
||||||
|
status: q.status,
|
||||||
|
currency: q.currency,
|
||||||
|
issueDate: q.issue_date,
|
||||||
|
validUntil: q.valid_until,
|
||||||
|
eventName: q.event_name,
|
||||||
|
eventDate: q.event_date,
|
||||||
|
netAmountMinor: q.net_amount_minor,
|
||||||
|
vatRate: q.vat_rate == null ? null : Number(q.vat_rate),
|
||||||
|
vatAmountMinor: q.vat_amount_minor,
|
||||||
|
shippingAmountMinor: q.shipping_amount_minor,
|
||||||
|
totalAmountMinor: q.total_amount_minor,
|
||||||
|
introText: q.intro_text,
|
||||||
|
outroText: q.outro_text,
|
||||||
|
sentAt: q.sent_at,
|
||||||
|
respondedAt: q.responded_at,
|
||||||
|
responseLockedAt: q.response_locked_at,
|
||||||
|
acceptedAt: q.accepted_at,
|
||||||
|
declinedAt: q.declined_at,
|
||||||
|
responseToken: tokensByQuote.get(q.id) || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer quotes list error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load quotes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- invoices (customer-facing read-only + PDF) ----------------------
|
||||||
|
// Mirrors /quotes — list owned by the customer with the same feature
|
||||||
|
// gate. Adds a PDF download endpoint so customers can grab the rendered
|
||||||
|
// invoice from their dashboard.
|
||||||
|
router.get('/invoices', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||||
|
if (!customer || customer.feature_bills === false || customer.feature_bills === 0) {
|
||||||
|
return res.status(403).json({ error: 'Invoices are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
|
||||||
|
}
|
||||||
|
// Visibility rules for the customer-facing list:
|
||||||
|
// - Hide `scheduled` always (drafts the admin is still tweaking).
|
||||||
|
// - Show `sent`, `overdue`, `paid` always (the customer's
|
||||||
|
// outstanding + paid history).
|
||||||
|
// - Show `cancelled` ONLY when `cancellation_storno_id IS NOT NULL`,
|
||||||
|
// i.e. the cancellation was made customer-visible via a
|
||||||
|
// Stornorechnung (migration 114). Soft-cancelled drafts stay
|
||||||
|
// hidden — the customer never saw the draft, so a "cancelled"
|
||||||
|
// phantom in their list would just be confusing.
|
||||||
|
// - Show `kind='storno'` rows (status='sent' after sendStorno)
|
||||||
|
// unconditionally — they're the customer's legal proof of
|
||||||
|
// cancellation and the only document with the §14c reversal.
|
||||||
|
const rows = await dbi('invoices')
|
||||||
|
.leftJoin('invoices as cancels_inv', 'invoices.cancels_invoice_id', 'cancels_inv.id')
|
||||||
|
.leftJoin('invoices as cancellation_storno', 'invoices.cancellation_storno_id', 'cancellation_storno.id')
|
||||||
|
.where({ 'invoices.customer_account_id': req.customer.id })
|
||||||
|
.whereNot('invoices.status', 'scheduled')
|
||||||
|
.whereNot('invoices.status', 'skipped')
|
||||||
|
.andWhere(function () {
|
||||||
|
this.whereNot('invoices.status', 'cancelled').orWhereNotNull('invoices.cancellation_storno_id');
|
||||||
|
})
|
||||||
|
.orderBy('invoices.issue_date', 'desc')
|
||||||
|
.orderBy('invoices.id', 'desc')
|
||||||
|
.select(
|
||||||
|
'invoices.id', 'invoices.kind', 'invoices.invoice_number', 'invoices.status', 'invoices.currency',
|
||||||
|
'invoices.issue_date', 'invoices.due_date',
|
||||||
|
// Inline event snapshot (migration 123) — the customer portal
|
||||||
|
// shows event_name next to the invoice number, mirroring the
|
||||||
|
// quotes list.
|
||||||
|
'invoices.event_name', 'invoices.event_date',
|
||||||
|
'invoices.installment_index', 'invoices.installment_total', 'invoices.installment_label',
|
||||||
|
'invoices.net_amount_minor', 'invoices.vat_rate', 'invoices.vat_amount_minor',
|
||||||
|
'invoices.shipping_amount_minor', 'invoices.total_amount_minor',
|
||||||
|
'invoices.paid_amount_minor', 'invoices.paid_at',
|
||||||
|
'invoices.late_fee_amount_minor', 'invoices.reminder_level', 'invoices.sent_at',
|
||||||
|
// Lineage — drives the Storno banner / cancelled-by-Storno
|
||||||
|
// indicator on the customer's bills page. Self-join the
|
||||||
|
// linked rows so we can surface the human invoice_number,
|
||||||
|
// not just the bare DB row id.
|
||||||
|
'invoices.cancels_invoice_id', 'invoices.cancellation_storno_id',
|
||||||
|
'cancels_inv.invoice_number as cancels_invoice_number',
|
||||||
|
'cancellation_storno.invoice_number as cancellation_storno_number',
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
invoices: rows.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
kind: i.kind || 'invoice',
|
||||||
|
invoiceNumber: i.invoice_number,
|
||||||
|
status: i.status,
|
||||||
|
currency: i.currency,
|
||||||
|
issueDate: i.issue_date,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
installmentIndex: i.installment_index,
|
||||||
|
installmentTotal: i.installment_total,
|
||||||
|
installmentLabel: i.installment_label,
|
||||||
|
netAmountMinor: i.net_amount_minor,
|
||||||
|
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
|
||||||
|
vatAmountMinor: i.vat_amount_minor,
|
||||||
|
shippingAmountMinor: i.shipping_amount_minor,
|
||||||
|
totalAmountMinor: i.total_amount_minor,
|
||||||
|
paidAmountMinor: i.paid_amount_minor,
|
||||||
|
paidAt: i.paid_at,
|
||||||
|
lateFeeAmountMinor: i.late_fee_amount_minor,
|
||||||
|
reminderLevel: i.reminder_level,
|
||||||
|
sentAt: i.sent_at,
|
||||||
|
cancelsInvoiceId: i.cancels_invoice_id || null,
|
||||||
|
cancelsInvoiceNumber: i.cancels_invoice_number || null,
|
||||||
|
cancellationStornoId: i.cancellation_storno_id || null,
|
||||||
|
cancellationStornoNumber: i.cancellation_storno_number || null,
|
||||||
|
eventName: i.event_name || null,
|
||||||
|
eventDate: i.event_date || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer invoice list error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load invoices' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-side quote PDF — mirrors the invoice PDF endpoint above.
|
||||||
|
* The customer can re-download any quote that's been sent to them
|
||||||
|
* (the public response page also uses this view). Draft quotes are
|
||||||
|
* hidden — they're not yet meant for the customer.
|
||||||
|
*/
|
||||||
|
router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Feature-gate identically to /quotes (list endpoint).
|
||||||
|
if (req.customer.feature_quotes === false || req.customer.feature_quotes === 0 || req.customer.feature_quotes === '0') {
|
||||||
|
return res.status(403).json({ error: 'Quotes are disabled for this account' });
|
||||||
|
}
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
const quote = await dbi('quotes')
|
||||||
|
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||||
|
.first();
|
||||||
|
if (!quote) return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
if (quote.status === 'draft') {
|
||||||
|
// Drafts aren't visible to the customer.
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
const quoteService = require('../services/quoteService');
|
||||||
|
const buf = await quoteService.renderQuotePdfBuffer(quote.id);
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: quote.quote_number,
|
||||||
|
customer,
|
||||||
|
fallback: `quote-${quote.id}`,
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer quote PDF error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to render quote PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
const invoice = await dbi('invoices')
|
||||||
|
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||||
|
.first();
|
||||||
|
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
if (['scheduled', 'cancelled', 'skipped'].includes(invoice.status)) {
|
||||||
|
// Don't expose scheduled drafts, cancelled docs, or
|
||||||
|
// skipped empty-monthly placeholders.
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
const invoiceService = require('../services/invoiceService');
|
||||||
|
const buf = await invoiceService.renderInvoicePdfBuffer(invoice.id);
|
||||||
|
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||||
|
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||||
|
const filename = buildPdfFilename({
|
||||||
|
docNumber: invoice.invoice_number,
|
||||||
|
customer,
|
||||||
|
fallback: `invoice-${invoice.id}`,
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.send(buf);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer invoice PDF error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to render invoice PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- contracts (customer-facing read-only + PDF + signed-PDF) -------
|
||||||
|
// Same shape as /quotes and /invoices. Drafts are hidden; everything
|
||||||
|
// from `sent` onwards is visible. Two PDF download endpoints because
|
||||||
|
// the signed PDF (stamped with signatures OR a wet-signed upload) is
|
||||||
|
// the authoritative copy customers want after both parties sign.
|
||||||
|
router.get('/contracts', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
if (!(await dbi.schema.hasTable('contracts'))) {
|
||||||
|
// Feature not migrated on this install yet.
|
||||||
|
return res.json({ contracts: [] });
|
||||||
|
}
|
||||||
|
const rows = await dbi('contracts')
|
||||||
|
.where({ customer_account_id: req.customer.id })
|
||||||
|
.whereNotIn('status', ['draft'])
|
||||||
|
.orderBy('issue_date', 'desc')
|
||||||
|
.orderBy('id', 'desc')
|
||||||
|
.select(
|
||||||
|
'id', 'contract_number', 'status', 'language',
|
||||||
|
'issue_date', 'valid_until', 'title',
|
||||||
|
'sent_at', 'signed_by_customer_at', 'signed_by_admin_at',
|
||||||
|
'signed_customer_name', 'signed_admin_name',
|
||||||
|
'pdf_path', 'signed_pdf_path',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Live tokens for the public sign page so customer dashboard can
|
||||||
|
// deep-link the "Sign now" button on `sent` contracts.
|
||||||
|
const tokensByContract = new Map();
|
||||||
|
if (rows.length > 0 && await dbi.schema.hasTable('contract_action_tokens')) {
|
||||||
|
const tokens = await dbi('contract_action_tokens')
|
||||||
|
.whereIn('contract_id', rows.map((r) => r.id))
|
||||||
|
.whereNull('used_at')
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.select('contract_id', 'token');
|
||||||
|
for (const tk of tokens) tokensByContract.set(tk.contract_id, tk.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
contracts: rows.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
contractNumber: c.contract_number,
|
||||||
|
status: c.status,
|
||||||
|
language: c.language,
|
||||||
|
issueDate: c.issue_date,
|
||||||
|
validUntil: c.valid_until,
|
||||||
|
title: c.title,
|
||||||
|
sentAt: c.sent_at,
|
||||||
|
signedByCustomerAt: c.signed_by_customer_at,
|
||||||
|
signedByAdminAt: c.signed_by_admin_at,
|
||||||
|
signedCustomerName: c.signed_customer_name,
|
||||||
|
signedAdminName: c.signed_admin_name,
|
||||||
|
// Surface flags only — no paths leaked to the customer.
|
||||||
|
hasPdf: !!c.pdf_path,
|
||||||
|
hasSignedPdf: !!c.signed_pdf_path,
|
||||||
|
responseToken: tokensByContract.get(c.id) || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer contracts list error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load contracts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db: dbi } = require('../database/db');
|
||||||
|
if (!(await dbi.schema.hasTable('contracts'))) {
|
||||||
|
return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
}
|
||||||
|
const contract = await dbi('contracts')
|
||||||
|
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
|
||||||
|
.first();
|
||||||
|
if (!contract) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
if (contract.status === 'draft') {
|
||||||
|
return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
}
|
||||||
|
// Prefer the wet-signed PDF when present, otherwise the system-
|
||||||
|
// generated PDF (signed in-browser, stamped, or unsigned).
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const filePath = contract.signed_pdf_path || contract.pdf_path;
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
|
// Render on-demand so customers who hit the link before the
|
||||||
|
// first send still get something usable.
|
||||||
|
const contractService = require('../services/contractService');
|
||||||
|
const buf = await contractService.renderContractPdfBuffer(contract.id);
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${contract.contract_number}.pdf"`);
|
||||||
|
return res.send(buf);
|
||||||
|
}
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Customer contract PDF error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to render contract PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Public → Contracts Routes
|
||||||
|
*
|
||||||
|
* Mounted at /api/public/contracts. NO authentication — the link in
|
||||||
|
* the customer's signing email is the only secret.
|
||||||
|
*
|
||||||
|
* Surface:
|
||||||
|
* GET /:token read-only contract view + included blocks
|
||||||
|
* POST /:token/sign body: { name, signatureDataUrl?, accepted: true }
|
||||||
|
* POST /:token/upload-signed-pdf multer single — customer uploads their wet-signed PDF
|
||||||
|
*
|
||||||
|
* No state mutation flows from /:token (GET) — only the two POST routes
|
||||||
|
* affect the contract. IP is captured for the signature evidence /
|
||||||
|
* upload audit row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const multer = require('multer');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { body, param } = require('express-validator');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||||
|
const contractService = require('../services/contractService');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { clientIpForAudit } = require('../utils/clientIp');
|
||||||
|
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const previewLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
const respondLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
const signedPdfStorage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(getStoragePath(), 'uploads/contracts/signed');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname) || '.pdf';
|
||||||
|
cb(null, `contract-token-${req.params.token.slice(0, 12)}-${Date.now()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedPdfUpload = multer({
|
||||||
|
storage: signedPdfStorage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
|
||||||
|
return cb(new Error('Only PDF files are allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-safe projection of the contract. We deliberately omit:
|
||||||
|
* - intro/outro text remain visible (customer-facing by design)
|
||||||
|
* - admin notes (none on contracts today)
|
||||||
|
* - admin IP + signature paths (signed_*_path is admin-only)
|
||||||
|
*
|
||||||
|
* The IP / signature image paths are NEVER exposed publicly even after
|
||||||
|
* signing — they're audit evidence.
|
||||||
|
*/
|
||||||
|
function publicContractView(contract, inclusions, customer, profile, locale) {
|
||||||
|
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
|
||||||
|
const blocksBySection = {};
|
||||||
|
for (const s of orderedSections) blocksBySection[s] = [];
|
||||||
|
for (const inc of inclusions) {
|
||||||
|
if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue;
|
||||||
|
const bodyEn = inc.body_text_snapshot || inc.block_body_text || '';
|
||||||
|
const bodyDe = inc.body_text_de_snapshot || inc.block_body_text_de || '';
|
||||||
|
// 1) Strip the leading `**Title**\n` line — the block.name is
|
||||||
|
// already rendered above as a bold sub-heading, so a bold
|
||||||
|
// first line in the body would duplicate it.
|
||||||
|
// 2) Strip remaining `**bold**` inline markers — the React sign
|
||||||
|
// page renders body as plain `whitespace-pre-line` text and
|
||||||
|
// has no inline-bold UI. The PDF path keeps them as bold
|
||||||
|
// runs via pdfService.renderBodyMarkdown.
|
||||||
|
const body = (locale === 'de' ? (bodyDe || bodyEn) : (bodyEn || bodyDe))
|
||||||
|
.replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, '')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1');
|
||||||
|
if (!blocksBySection[inc.section]) continue;
|
||||||
|
blocksBySection[inc.section].push({
|
||||||
|
blockId: inc.block_id,
|
||||||
|
section: inc.section,
|
||||||
|
position: inc.position,
|
||||||
|
name: inc.block_name,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sections = orderedSections
|
||||||
|
.map((s) => ({ section: s, blocks: blocksBySection[s] }))
|
||||||
|
.filter((s) => s.blocks.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contractNumber: contract.contract_number,
|
||||||
|
status: contract.status,
|
||||||
|
language: contract.language,
|
||||||
|
issueDate: contract.issue_date,
|
||||||
|
validUntil: contract.valid_until,
|
||||||
|
title: contract.title,
|
||||||
|
introText: contract.intro_text,
|
||||||
|
outroText: contract.outro_text,
|
||||||
|
sentAt: contract.sent_at,
|
||||||
|
signedByCustomerAt: contract.signed_by_customer_at,
|
||||||
|
signedByAdminAt: contract.signed_by_admin_at,
|
||||||
|
signedCustomerName: contract.signed_customer_name,
|
||||||
|
signedAdminName: contract.signed_admin_name,
|
||||||
|
// The customer's own IP is fine to surface back — it's THEIR
|
||||||
|
// identifier on the audit trail. The admin's IP is NOT exposed
|
||||||
|
// publicly: it's a counter-party's identifier (operator's office /
|
||||||
|
// home network) and shouldn't reach the customer's browser via
|
||||||
|
// a token-only-secret endpoint. Admin sees their own IP on the
|
||||||
|
// admin detail page; customer doesn't need it.
|
||||||
|
signedCustomerIp: contract.signed_customer_ip || null,
|
||||||
|
// signed_pdf_path itself is admin-only; we just flag presence so
|
||||||
|
// the public page can show a "wet-signed copy attached" hint.
|
||||||
|
hasSignedPdf: !!contract.signed_pdf_path,
|
||||||
|
// SHA-256 of the on-disk PDFs — surfaced so the customer can
|
||||||
|
// re-hash their downloaded copy and confirm it matches what
|
||||||
|
// we issued. Audit-trail evidence #1 from the maintainer plan.
|
||||||
|
pdfSha256: contract.pdf_sha256 || null,
|
||||||
|
signedPdfSha256: contract.signed_pdf_sha256 || null,
|
||||||
|
canSign: contract.status === 'sent',
|
||||||
|
sections,
|
||||||
|
recipient: customer ? {
|
||||||
|
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
|
||||||
|
companyName: customer.company_name,
|
||||||
|
email: customer.email,
|
||||||
|
} : null,
|
||||||
|
issuer: profile ? {
|
||||||
|
companyName: profile.company_name,
|
||||||
|
addressLine1: profile.address_line1,
|
||||||
|
postalCode: profile.postal_code,
|
||||||
|
city: profile.city,
|
||||||
|
email: profile.email,
|
||||||
|
website: profile.website,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:token',
|
||||||
|
previewLimiter,
|
||||||
|
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const tokenRow = await loadActionToken(req, res, {
|
||||||
|
tableName: 'contract_action_tokens',
|
||||||
|
token: req.params.token,
|
||||||
|
});
|
||||||
|
if (!tokenRow) return;
|
||||||
|
const data = await contractService.getContractById(tokenRow.contract_id);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
const customer = await db('customer_accounts').where({ id: data.contract.customer_account_id }).first();
|
||||||
|
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
// Surface the admin-tunable behaviour toggles on the view so the
|
||||||
|
// React page can hide the upload-PDF section when disabled and
|
||||||
|
// enforce the drawn-signature requirement client-side. The server
|
||||||
|
// re-enforces both, so client tampering only changes the UX.
|
||||||
|
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
|
||||||
|
const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true;
|
||||||
|
const view = publicContractView(
|
||||||
|
data.contract,
|
||||||
|
data.inclusions,
|
||||||
|
customer,
|
||||||
|
profile,
|
||||||
|
data.contract.language || 'de',
|
||||||
|
);
|
||||||
|
view.allowPdfUpload = allowPdfUpload;
|
||||||
|
view.requireDrawnSignature = requireDrawnSignature;
|
||||||
|
return successResponse(res, { contract: view });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:token/sign',
|
||||||
|
respondLimiter,
|
||||||
|
[
|
||||||
|
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
|
||||||
|
body('name').isString().isLength({ min: 1, max: 255 }),
|
||||||
|
body('accepted').isBoolean(),
|
||||||
|
body('signatureDataUrl').optional({ nullable: true }).isString(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
// Audit IP source: req.ip ONLY. See utils/clientIp.js for the
|
||||||
|
// full rationale — reading X-Forwarded-For directly bypassed
|
||||||
|
// Express's trust-proxy safety net and let direct (non-proxied)
|
||||||
|
// POSTs spoof the audit IP, defeating the legal-evidence promise
|
||||||
|
// of the contract signing flow. Operators whose nginx topology
|
||||||
|
// needs different trust rules adjust `TRUST_PROXY` in server.js.
|
||||||
|
const ip = clientIpForAudit(req);
|
||||||
|
try {
|
||||||
|
const result = await contractService.recordCustomerSignature({
|
||||||
|
token: req.params.token,
|
||||||
|
name: req.body.name,
|
||||||
|
signatureDataUrl: req.body.signatureDataUrl,
|
||||||
|
accepted: req.body.accepted === true,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
return successResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status) {
|
||||||
|
return res.status(err.status).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server-side guard for the "allow PDF upload" toggle. When the admin
|
||||||
|
// turns it off in Settings → CRM behaviour → Contracts the public sign
|
||||||
|
// page hides the upload section, but a hand-crafted POST would still
|
||||||
|
// hit this route — refuse here too BEFORE multer reads the body so a
|
||||||
|
// disabled-toggle install never writes attacker bytes to disk.
|
||||||
|
async function uploadSignedPdfSettingGuard(req, res, next) {
|
||||||
|
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
|
||||||
|
if (!allowPdfUpload) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Uploading a wet-signed PDF is disabled for this installation. Please sign in your browser instead.',
|
||||||
|
code: 'UPLOAD_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:token/upload-signed-pdf',
|
||||||
|
respondLimiter,
|
||||||
|
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
|
||||||
|
// CRITICAL ORDERING: setting guard + token guard run BEFORE multer.
|
||||||
|
// Previously these checks lived after multer.single, which meant a
|
||||||
|
// disabled-toggle install OR an expired/invalid token still cost a
|
||||||
|
// disk write — captured tokens could be replayed to spam the disk
|
||||||
|
// up to multer's 10 MB cap per request. Pre-multer rejection costs
|
||||||
|
// a DB lookup and nothing more.
|
||||||
|
uploadSignedPdfSettingGuard,
|
||||||
|
preMulterTokenGuard('contract_action_tokens'),
|
||||||
|
signedPdfUpload.single('file'),
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const tokenRow = req.publicTokenRow; // attached by preMulterTokenGuard
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
|
||||||
|
}
|
||||||
|
const result = await contractService.attachSignedPdfUpload(
|
||||||
|
tokenRow.contract_id,
|
||||||
|
req.file.path,
|
||||||
|
'customer',
|
||||||
|
);
|
||||||
|
// Mark the token as used so the link can't be re-played.
|
||||||
|
// IP storage is gated by the crm_contracts_store_ip setting so
|
||||||
|
// privacy-strict operators can opt out — same toggle that gates
|
||||||
|
// the in-browser-sign IP captures. See utils/clientIp.js for
|
||||||
|
// why we trust req.ip only.
|
||||||
|
const rawIp = clientIpForAudit(req);
|
||||||
|
const storeIpEnabled = (await getAppSetting('crm_contracts_store_ip')) !== false;
|
||||||
|
await db('contract_action_tokens').where({ id: tokenRow.id }).update({
|
||||||
|
used_at: new Date(),
|
||||||
|
used_action: 'uploaded_signed_pdf',
|
||||||
|
used_ip: storeIpEnabled ? rawIp : null,
|
||||||
|
});
|
||||||
|
return successResponse(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public PDF download — token-scoped. Once the customer has signed,
|
||||||
|
* they can re-fetch the signed copy from the same link rather than
|
||||||
|
* waiting for the contract_fully_signed email (which only arrives
|
||||||
|
* after admin counter-sign). Streams signed_pdf_path when present,
|
||||||
|
* falls back to pdf_path. Returns 410 once the link has expired.
|
||||||
|
*
|
||||||
|
* Security note: this route deliberately honours `expires_at` now —
|
||||||
|
* previous behaviour was "expired tokens still allow downloads, the
|
||||||
|
* customer may need their signed copy after the window closes" but
|
||||||
|
* that turned the token into a permanent unauthenticated download
|
||||||
|
* URL once leaked (referer headers, browser history, email forward).
|
||||||
|
* Customers needing a post-expiry copy receive the signed PDF in the
|
||||||
|
* `contract_fully_signed` email, OR the admin can issue a fresh
|
||||||
|
* download link via the admin detail page.
|
||||||
|
*
|
||||||
|
* Future enhancement (audit: "public token model rework"): swap the
|
||||||
|
* long-lived contract token for a short-lived download sub-token
|
||||||
|
* (~5 min) generated after sign, so the download URL itself never
|
||||||
|
* embeds the long-lived secret. Tracked in the CRM backlog.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:token/pdf',
|
||||||
|
previewLimiter,
|
||||||
|
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const tokenRow = await loadActionToken(req, res, {
|
||||||
|
tableName: 'contract_action_tokens',
|
||||||
|
token: req.params.token,
|
||||||
|
});
|
||||||
|
if (!tokenRow) return;
|
||||||
|
const contract = await db('contracts').where({ id: tokenRow.contract_id }).first();
|
||||||
|
if (!contract) return res.status(404).json({ error: 'Contract not found' });
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { assertContractPdfPath } = require('../utils/safePath');
|
||||||
|
const filePath = contract.signed_pdf_path || contract.pdf_path;
|
||||||
|
// Content-Disposition: attachment + Referrer-Policy: no-referrer
|
||||||
|
// so the long-lived contract token doesn't leak via referer
|
||||||
|
// headers if the customer opens the PDF in an external viewer
|
||||||
|
// that loads remote resources.
|
||||||
|
res.set('Referrer-Policy', 'no-referrer');
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
|
// Render on-demand so the link works even if the on-disk
|
||||||
|
// file was wiped (cleanup, S3 sync, etc.).
|
||||||
|
const contractService = require('../services/contractService');
|
||||||
|
const buf = await contractService.renderContractPdfBuffer(contract.id);
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${contract.contract_number}.pdf"`);
|
||||||
|
return res.send(buf);
|
||||||
|
}
|
||||||
|
// C.7 — defence-in-depth: reject if filePath resolves outside the
|
||||||
|
// contract storage roots. The customer signing token is far less
|
||||||
|
// privileged than an admin, so getting this wrong has higher blast
|
||||||
|
// radius (a forged token could otherwise read any file the node
|
||||||
|
// process has access to). assertContractPdfPath throws AppError
|
||||||
|
// which the error middleware converts to a clean 403/404.
|
||||||
|
const safePath = assertContractPdfPath(filePath);
|
||||||
|
res.set('Content-Type', 'application/pdf');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${path.basename(safePath)}"`);
|
||||||
|
fs.createReadStream(safePath).pipe(res);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Public → Invoice payment-check Routes
|
||||||
|
*
|
||||||
|
* Mounted at /api/public/payment-check. NO authentication — the
|
||||||
|
* admin's email link carries a 64-char hex token; that's the only
|
||||||
|
* gate. The page at /payment-check/:token uses these endpoints to:
|
||||||
|
*
|
||||||
|
* GET /:token read invoice summary for the page
|
||||||
|
* POST /:token record the admin's selection:
|
||||||
|
* action: 'paid_full' | 'partial' | 'unpaid'
|
||||||
|
* amountMinor: optional, for 'partial'
|
||||||
|
*
|
||||||
|
* Mirrors the publicQuotes.js shape (rate limits, token format
|
||||||
|
* validation, error code surface) so the same defensive patterns
|
||||||
|
* apply.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param } = require('express-validator');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const invoiceService = require('../services/invoiceService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 30 reads / minute / IP; 10 records / minute / IP.
|
||||||
|
const previewLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
const recordLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:token',
|
||||||
|
previewLimiter,
|
||||||
|
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
try {
|
||||||
|
const view = await invoiceService.getPaymentCheckByToken(req.params.token);
|
||||||
|
|
||||||
|
// Branding block — same shape `publicQuotes.js` returns so the
|
||||||
|
// frontend can render a consistent header (logo + company
|
||||||
|
// name) and respect the admin's branding colour palette. Web
|
||||||
|
// pages use the global Settings → Branding logo, NOT the
|
||||||
|
// dedicated PDF logo (business_profile.logo_path is print-
|
||||||
|
// only).
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
|
||||||
|
const issuer = profile ? {
|
||||||
|
companyName: profile.company_name || '',
|
||||||
|
email: profile.email || '',
|
||||||
|
website: profile.website || '',
|
||||||
|
logoUrl: (() => {
|
||||||
|
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
|
||||||
|
return `/uploads/${raw.replace(/^uploads\//, '')}`;
|
||||||
|
})(),
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return successResponse(res, { invoice: view, issuer });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'TOKEN_ALREADY_USED') {
|
||||||
|
return res.status(410).json({
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
usedAt: err.usedAt,
|
||||||
|
usedAction: err.usedAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:token',
|
||||||
|
recordLimiter,
|
||||||
|
[
|
||||||
|
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
|
||||||
|
body('action').isIn(['paid_full', 'paid_with_skonto', 'partial', 'unpaid']),
|
||||||
|
body('amountMinor').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const result = await invoiceService.recordPaymentCheckAction({
|
||||||
|
token: req.params.token,
|
||||||
|
action: req.body.action,
|
||||||
|
amountMinor: req.body.amountMinor,
|
||||||
|
ip: req.ip,
|
||||||
|
adminId: null,
|
||||||
|
});
|
||||||
|
return successResponse(res, result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Public → Quotes Routes
|
||||||
|
*
|
||||||
|
* Mounted at /api/public/quotes. NO authentication — the link in the
|
||||||
|
* customer email is the only secret. The route layer must:
|
||||||
|
* - never leak admin-only fields (internal_notes, etc.)
|
||||||
|
* - rate-limit by IP/token to soften brute-force token guessing
|
||||||
|
* - honour the 15-min re-toggle window enforced at the service layer
|
||||||
|
*
|
||||||
|
* Surface:
|
||||||
|
* GET /:token read-only quote view for the customer
|
||||||
|
* POST /:token/respond body: { action: 'accept' | 'decline' }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param } = require('express-validator');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const quoteService = require('../services/quoteService');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { clientIpForAudit } = require('../utils/clientIp');
|
||||||
|
const { loadActionToken } = require('../utils/publicTokenGuards');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Rate-limit: 30 token previews per IP per minute, 10 responses.
|
||||||
|
const previewLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
const respondLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) {
|
||||||
|
return {
|
||||||
|
quoteNumber: quote.quote_number,
|
||||||
|
status: quote.status,
|
||||||
|
language: quote.language,
|
||||||
|
currency: quote.currency,
|
||||||
|
issueDate: quote.issue_date,
|
||||||
|
validUntil: quote.valid_until,
|
||||||
|
eventName: quote.event_name,
|
||||||
|
eventDate: quote.event_date,
|
||||||
|
eventTimeStart: quote.event_time_start,
|
||||||
|
eventTimeEnd: quote.event_time_end,
|
||||||
|
introText: quote.intro_text,
|
||||||
|
outroText: quote.outro_text,
|
||||||
|
// Money — public surface.
|
||||||
|
netAmountMinor: quote.net_amount_minor,
|
||||||
|
vatRate: quote.vat_rate == null ? null : Number(quote.vat_rate),
|
||||||
|
vatAmountMinor: quote.vat_amount_minor,
|
||||||
|
shippingAmountMinor: quote.shipping_amount_minor,
|
||||||
|
totalAmountMinor: quote.total_amount_minor,
|
||||||
|
// Response state — drives the page UI.
|
||||||
|
respondedAt: quote.responded_at,
|
||||||
|
responseLockedAt: quote.response_locked_at,
|
||||||
|
canRespond: !!(quote.status === 'sent' || (
|
||||||
|
quote.responded_at && quote.response_locked_at &&
|
||||||
|
new Date(quote.response_locked_at).getTime() > Date.now()
|
||||||
|
)),
|
||||||
|
lineItems: lineItems.map((li) => ({
|
||||||
|
position: li.position,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
description: li.description,
|
||||||
|
unitPriceMinor: li.unit_price_minor,
|
||||||
|
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
|
||||||
|
lineTotalMinor: li.line_total_minor,
|
||||||
|
})),
|
||||||
|
recipient: customer ? {
|
||||||
|
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
|
||||||
|
email: customer.email,
|
||||||
|
companyName: customer.company_name,
|
||||||
|
} : null,
|
||||||
|
// Terms of Service surfaced to the customer when the global
|
||||||
|
// `crm_quotes_tos_required` flag is on. The text + URL are
|
||||||
|
// included unconditionally so admins can opt to display them
|
||||||
|
// without blocking acceptance; the frontend gates the checkbox.
|
||||||
|
// Snapshot is rendered when the quote has already been accepted
|
||||||
|
// so the customer sees exactly what they agreed to, not the
|
||||||
|
// current ToS text (which may have changed).
|
||||||
|
tos: {
|
||||||
|
required: tosRequired === true,
|
||||||
|
text: quote.tos_text_snapshot || tosText || '',
|
||||||
|
url: tosUrl || '',
|
||||||
|
acceptedAt: quote.tos_accepted_at || null,
|
||||||
|
},
|
||||||
|
issuer: profile ? {
|
||||||
|
companyName: profile.company_name,
|
||||||
|
email: profile.email,
|
||||||
|
website: profile.website,
|
||||||
|
footerLine: profile.footer_line,
|
||||||
|
// Logo source for the web quote page is ONLY the global
|
||||||
|
// Settings → Branding logo (`app_settings.branding_logo_url`).
|
||||||
|
//
|
||||||
|
// `business_profile.logo_path` is intentionally NOT consulted
|
||||||
|
// here — it's a dedicated PDF lightmode logo (PDFs always
|
||||||
|
// print on white paper, so admins upload a dark variant
|
||||||
|
// there). On the web page the existing site branding already
|
||||||
|
// serves both light + dark modes correctly, so falling back
|
||||||
|
// to a PDF-only image would override that with a light
|
||||||
|
// version that doesn't read in dark mode.
|
||||||
|
logoUrl: (() => {
|
||||||
|
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
|
||||||
|
return `/uploads/${raw.replace(/^uploads\//, '')}`;
|
||||||
|
})(),
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:token',
|
||||||
|
previewLimiter,
|
||||||
|
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
const tokenRow = await loadActionToken(req, res, {
|
||||||
|
tableName: 'quote_action_tokens',
|
||||||
|
token: req.params.token,
|
||||||
|
});
|
||||||
|
if (!tokenRow) return;
|
||||||
|
const data = await quoteService.getQuoteById(tokenRow.quote_id);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
|
||||||
|
const customer = await db('customer_accounts').where({ id: data.quote.customer_account_id }).first();
|
||||||
|
const businessProfileService = require('../services/businessProfileService');
|
||||||
|
const { profile } = await businessProfileService.getProfile();
|
||||||
|
// Pull the three ToS keys via the shared helper so it works
|
||||||
|
// regardless of how setting_value is encoded (JSON-stringified vs
|
||||||
|
// raw). All three are optional.
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const tosRequired = await getAppSetting('crm_quotes_tos_required', false);
|
||||||
|
const tosText = await getAppSetting('crm_quotes_tos_text', '');
|
||||||
|
const tosUrl = await getAppSetting('crm_quotes_tos_url', '');
|
||||||
|
// Fallback logo when business_profile has no dedicated CRM logo
|
||||||
|
// — admins typically upload one logo via Settings → Branding and
|
||||||
|
// expect it to flow through the customer-facing pages too.
|
||||||
|
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:token/respond',
|
||||||
|
respondLimiter,
|
||||||
|
[
|
||||||
|
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
|
||||||
|
body('action').isIn(['accept', 'decline']),
|
||||||
|
// ToS box: optional flag, only meaningful when the global
|
||||||
|
// `crm_quotes_tos_required` setting is on. Service enforces.
|
||||||
|
body('tosAccepted').optional().isBoolean(),
|
||||||
|
],
|
||||||
|
handleAsync(async (req, res) => {
|
||||||
|
validateRequest(req);
|
||||||
|
try {
|
||||||
|
// See utils/clientIp.js — trust req.ip (configured via Express
|
||||||
|
// trust-proxy), never read X-Forwarded-For directly.
|
||||||
|
const ip = clientIpForAudit(req);
|
||||||
|
const result = await quoteService.recordResponse({
|
||||||
|
token: req.params.token,
|
||||||
|
action: req.body.action,
|
||||||
|
ip,
|
||||||
|
tosAccepted: req.body.tosAccepted === true,
|
||||||
|
});
|
||||||
|
return successResponse(res, { status: result.status, lockedAt: result.lockedAt });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'RESPONSE_LOCKED') {
|
||||||
|
return res.status(423).json({
|
||||||
|
error: err.message,
|
||||||
|
code: 'RESPONSE_LOCKED',
|
||||||
|
currentStatus: err.currentStatus,
|
||||||
|
lockedAt: err.lockedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -105,6 +105,19 @@ router.get('/', async (req, res) => {
|
|||||||
default_language: settingsObject.general_default_language || 'en',
|
default_language: settingsObject.general_default_language || 'en',
|
||||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||||
general_date_format: settingsObject.general_date_format || 'PPP',
|
general_date_format: settingsObject.general_date_format || 'PPP',
|
||||||
|
// '12h' / '24h' — controls how times are rendered in admin +
|
||||||
|
// customer views via the useLocalizedDate hook. The underlying
|
||||||
|
// storage is always HH:mm (24h); only the displayed form toggles.
|
||||||
|
// Default '24h' to match the operator's CH/DE locale.
|
||||||
|
general_time_format: settingsObject.general_time_format === '12h' ? '12h' : '24h',
|
||||||
|
// CRM overview tile visibility (admin-only — these are surfaced
|
||||||
|
// via the public-settings endpoint because the dashboard reads
|
||||||
|
// them on mount and the value never depends on auth state. All
|
||||||
|
// four default ON; only explicit false hides the tile.
|
||||||
|
crm_overview_show_revenue: settingsObject.crm_overview_show_revenue !== false,
|
||||||
|
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
|
||||||
|
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
|
||||||
|
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
|
||||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Shared render-context helpers for the three document services
|
||||||
|
* (quoteService, invoiceService, contractService).
|
||||||
|
*
|
||||||
|
* **Why this file exists**
|
||||||
|
*
|
||||||
|
* The audit flagged that the issuer + recipient blocks of
|
||||||
|
* `buildRenderContext` were copy-pasted across all three services and
|
||||||
|
* had already drifted (contractService's recipient gated `attentionLine`
|
||||||
|
* on `trimmedCompany` while quote/invoice fire it whenever a person
|
||||||
|
* name is present). The PDF renderer happens to gate on `hasCompany`
|
||||||
|
* downstream, so neither variant produced wrong output — but the drift
|
||||||
|
* is a maintenance trap and any future renderer change relying on the
|
||||||
|
* raw string would fail surprisingly on one document type.
|
||||||
|
*
|
||||||
|
* Two helpers live here:
|
||||||
|
*
|
||||||
|
* - `buildIssuerBlock(profile, resolvedLogoPath, options?)` — the
|
||||||
|
* full issuer shape consumed by pdfService.drawIssuerBlock. Honors
|
||||||
|
* the existing pdf_show_logo / pdf_show_company_name visibility
|
||||||
|
* toggles, logo-height, folding-marks, etc. `options.quoteToggles`
|
||||||
|
* adds the two quote-only fields (`quoteShowNetDays`,
|
||||||
|
* `quoteShowSkonto`) — they are silently dropped for invoice and
|
||||||
|
* contract callers so the same helper serves all three doc types.
|
||||||
|
*
|
||||||
|
* - `buildRecipientBlock(profile, customer)` — the recipient address
|
||||||
|
* block. Honors the maintainer spec: companies bold the company
|
||||||
|
* name on line 1 + "z. Hd. <person>" on line 2; private customers
|
||||||
|
* bold the person name on line 1 with no z.Hd. line at all. The
|
||||||
|
* attentionLine string is always populated when a person+salutation
|
||||||
|
* exists (the downstream renderer gates emission on hasCompany);
|
||||||
|
* keeping the string non-empty preserves the back-pointer for
|
||||||
|
* audit/debug surfaces that read the context directly.
|
||||||
|
*
|
||||||
|
* **What stayed in each service**
|
||||||
|
*
|
||||||
|
* Doc-type-specific fields (line items, totals, payment-term resolution,
|
||||||
|
* Skonto fallback chain, doc/title block, contract signatures + audit
|
||||||
|
* trail, source-quote line-items table) all stay where they are. Only
|
||||||
|
* the issuer + recipient blocks are extracted, since those are
|
||||||
|
* verbatim duplicates across all three services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `issuer` field for a render context. The shape mirrors the
|
||||||
|
* legacy inline construction exactly so existing callers + the
|
||||||
|
* pdfService.drawIssuerBlock consumer don't need to change.
|
||||||
|
*
|
||||||
|
* @param {object} profile business_profile row (may be empty)
|
||||||
|
* @param {string|null} logoPath pre-resolved absolute logo path (see resolveLogoFile)
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.quoteToggles] include pdf_quote_show_net_days
|
||||||
|
* + pdf_quote_show_skonto fields
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function buildIssuerBlock(profile, logoPath, options = {}) {
|
||||||
|
if (!profile) return {};
|
||||||
|
const base = {
|
||||||
|
companyName: profile.company_name,
|
||||||
|
addressLine1: profile.address_line1,
|
||||||
|
addressLine2: profile.address_line2,
|
||||||
|
postalCode: profile.postal_code,
|
||||||
|
city: profile.city,
|
||||||
|
state: profile.state,
|
||||||
|
countryCode: profile.country_code,
|
||||||
|
phone: profile.phone,
|
||||||
|
mobile: profile.mobile,
|
||||||
|
email: profile.email,
|
||||||
|
website: profile.website,
|
||||||
|
footerLine: profile.footer_line,
|
||||||
|
vatId: profile.vat_id,
|
||||||
|
// Steuernummer (migration 139). Rendered alongside VAT-ID on the
|
||||||
|
// PDF issuer block — §14 UStG requires one or both on every
|
||||||
|
// invoice. Kleinunternehmer without a USt-IdNr. carry only this.
|
||||||
|
taxId: profile.tax_id || null,
|
||||||
|
// pre-resolved absolute path; renderer never re-resolves.
|
||||||
|
logoPath,
|
||||||
|
pdfFontTtfPath: profile.pdf_font_ttf_path,
|
||||||
|
// Bundled fonts dropdown (migration 121). When set, pdfService loads
|
||||||
|
// <family>/400.ttf + <family>/700.ttf from backend/assets/fonts/.
|
||||||
|
// Priority: pdfFontTtfPath wins if both are present.
|
||||||
|
pdfFontFamily: profile.pdf_font_family || null,
|
||||||
|
// Free-text country name override (migration 107).
|
||||||
|
countryName: profile.country_name || null,
|
||||||
|
// Visibility toggles (migration 106). Default true when the column
|
||||||
|
// is missing on older installs that haven't migrated yet — keeps
|
||||||
|
// the previously implicit "always show" behavior pinned.
|
||||||
|
showLogo: profile.pdf_show_logo == null ? true
|
||||||
|
: (profile.pdf_show_logo === true || profile.pdf_show_logo === 1 || profile.pdf_show_logo === '1'),
|
||||||
|
showCompanyName: profile.pdf_show_company_name == null ? true
|
||||||
|
: (profile.pdf_show_company_name === true || profile.pdf_show_company_name === 1 || profile.pdf_show_company_name === '1'),
|
||||||
|
// Layout customisation (migration 108).
|
||||||
|
logoHeight: profile.pdf_logo_height == null ? 56 : Number(profile.pdf_logo_height),
|
||||||
|
companyNameInline: profile.pdf_company_name_inline === true || profile.pdf_company_name_inline === 1 || profile.pdf_company_name_inline === '1',
|
||||||
|
foldingMarks: profile.pdf_folding_marks || 'none',
|
||||||
|
};
|
||||||
|
if (options.quoteToggles) {
|
||||||
|
// Quote payment-block toggles (migration 110). Quote-only — invoices
|
||||||
|
// ignore these and always show the payment block. Default FALSE
|
||||||
|
// when the column is missing (a quote is an offer, not a demand
|
||||||
|
// for payment; admins opt IN via the Business profile UI).
|
||||||
|
base.quoteShowNetDays = profile.pdf_quote_show_net_days === true
|
||||||
|
|| profile.pdf_quote_show_net_days === 1 || profile.pdf_quote_show_net_days === '1';
|
||||||
|
base.quoteShowSkonto = profile.pdf_quote_show_skonto === true
|
||||||
|
|| profile.pdf_quote_show_skonto === 1 || profile.pdf_quote_show_skonto === '1';
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `recipient` field for a render context. Maintainer spec:
|
||||||
|
*
|
||||||
|
* - customer.company_name set → bold company on line 1, then
|
||||||
|
* "z. Hd. <person>" on line 2 (rendered by pdfService when
|
||||||
|
* hasCompany is true).
|
||||||
|
* - else → bold person/display_name/email on line 1, NO z.Hd. line
|
||||||
|
* (avoids "Luca Bresch / z. Hd. Luca Bresch" duplication).
|
||||||
|
*
|
||||||
|
* Empty-string trim guard: customer rows saved with company_name = ""
|
||||||
|
* (not NULL) used to engage the company-header path with a blank line
|
||||||
|
* before the trim was added.
|
||||||
|
*
|
||||||
|
* @param {object} profile business_profile row (may be empty)
|
||||||
|
* @param {object} customer customer_accounts row (may be null)
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function buildRecipientBlock(profile, customer) {
|
||||||
|
const trimmedCompany = (customer?.company_name || '').trim();
|
||||||
|
const personFull = [customer?.first_name, customer?.last_name]
|
||||||
|
.map((s) => (s || '').trim()).filter(Boolean).join(' ');
|
||||||
|
const headerWithCompany = !!trimmedCompany;
|
||||||
|
const header = trimmedCompany
|
||||||
|
|| personFull
|
||||||
|
|| (customer?.display_name || '').trim()
|
||||||
|
|| customer?.email
|
||||||
|
|| '';
|
||||||
|
// Always populate the attention string when we have a person —
|
||||||
|
// pdfService.drawRecipientBlock gates emission on hasCompany so the
|
||||||
|
// dead-data case (no company, has person) doesn't end up on the
|
||||||
|
// PDF, but having the string available means audit views can show
|
||||||
|
// it. This unifies the previously-drifted contractService and
|
||||||
|
// quote/invoice behavior under the renderer-aware contract.
|
||||||
|
const attentionParts = [customer?.salutation, personFull].filter(Boolean);
|
||||||
|
const attentionLine = attentionParts.length > 0
|
||||||
|
? `z. Hd. ${attentionParts.join(' ')}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
issuerLine: profile?.company_name
|
||||||
|
? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}`
|
||||||
|
: '',
|
||||||
|
companyName: header,
|
||||||
|
hasCompany: headerWithCompany,
|
||||||
|
attentionLine,
|
||||||
|
// Honorific + last name for personalised salutation
|
||||||
|
// ("Sehr geehrter Herr Bresch,"). Renderer requires BOTH.
|
||||||
|
salutation: customer?.salutation || null,
|
||||||
|
lastName: (customer?.last_name || '').trim() || null,
|
||||||
|
addressLine1: customer?.address_line1,
|
||||||
|
addressLine2: customer?.address_line2,
|
||||||
|
postalCode: customer?.postal_code,
|
||||||
|
city: customer?.city,
|
||||||
|
// Country name override (migration 107); falls back to the
|
||||||
|
// locale-aware COUNTRY_NAMES lookup on countryCodeIso in pdfService.
|
||||||
|
country: customer?.country_name || null,
|
||||||
|
countryCodeIso: customer?.country_code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildIssuerBlock,
|
||||||
|
buildRecipientBlock,
|
||||||
|
};
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* businessProfileService — single source of truth for the issuer block
|
||||||
|
* printed at the top of every quote/invoice PDF.
|
||||||
|
*
|
||||||
|
* Two tables back this:
|
||||||
|
* - business_profile singleton row (id=1) seeded by migration 102
|
||||||
|
* - business_bank_accounts 1:N from business_profile
|
||||||
|
*
|
||||||
|
* Bank accounts are partitioned by currency: at most one default per
|
||||||
|
* currency. The Quote/Invoice editors auto-pick the matching default when
|
||||||
|
* the user changes the doc currency. The defaulting rule is enforced at
|
||||||
|
* the service layer (inside a transaction) — the DB doesn't have a
|
||||||
|
* partial unique index so we can't rely on it cross-dialect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db, withRetry } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
|
const ALLOWED_PROFILE_FIELDS = [
|
||||||
|
'company_name',
|
||||||
|
'address_line1',
|
||||||
|
'address_line2',
|
||||||
|
'postal_code',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'country_code',
|
||||||
|
// Free-text country name (migration 107). Overrides the lookup
|
||||||
|
// when set; falls back to COUNTRY_NAMES[locale][country_code] in
|
||||||
|
// the PDF renderer when blank.
|
||||||
|
'country_name',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'email',
|
||||||
|
'website',
|
||||||
|
'vat_id',
|
||||||
|
// Steuernummer (migration 139). DE/AT §14 UStG accepts either
|
||||||
|
// USt-IdNr. (vat_id) or local tax number (tax_id) on invoices; many
|
||||||
|
// Kleinunternehmer only have the latter.
|
||||||
|
'tax_id',
|
||||||
|
'vat_label',
|
||||||
|
'vat_rate_default',
|
||||||
|
'default_currency',
|
||||||
|
'default_locale',
|
||||||
|
'default_qr_format',
|
||||||
|
'footer_line',
|
||||||
|
'logo_path',
|
||||||
|
// Bundled-fonts dropdown (migration 121). Stores the on-disk
|
||||||
|
// directory name under backend/assets/fonts/ (e.g. "Inter",
|
||||||
|
// "Playfair-Display"). pdfService loads <family>/400.ttf as body
|
||||||
|
// and <family>/700.ttf as bold at render time.
|
||||||
|
//
|
||||||
|
// Note: the legacy `pdf_font_ttf_path` column (migration 103) is
|
||||||
|
// intentionally NOT in this whitelist anymore — the UI for setting
|
||||||
|
// it was retired in favour of the dropdown. Existing values keep
|
||||||
|
// working at render time (pdfService still reads the column with
|
||||||
|
// priority), but new writes go exclusively through pdf_font_family.
|
||||||
|
'pdf_font_family',
|
||||||
|
// PDF letterhead visibility toggles (migration 106). Defaults true
|
||||||
|
// to keep existing PDFs visually identical after the migration runs.
|
||||||
|
'pdf_show_logo',
|
||||||
|
'pdf_show_company_name',
|
||||||
|
// PDF layout customisation (migration 108): folding marks at the
|
||||||
|
// page edge, logo banner height in pt, and a toggle to render the
|
||||||
|
// company name as inline plain text rather than as a bold title.
|
||||||
|
'pdf_folding_marks',
|
||||||
|
'pdf_logo_height',
|
||||||
|
'pdf_company_name_inline',
|
||||||
|
// Quote payment-block toggles (migration 110). Invoices always
|
||||||
|
// show the full payment block; these only affect quote PDFs.
|
||||||
|
'pdf_quote_show_net_days',
|
||||||
|
'pdf_quote_show_skonto',
|
||||||
|
// IANA timezone string for the admin calendar (migration 137). Used
|
||||||
|
// by the calendar UI to render timed blocks in the operator's
|
||||||
|
// working tz. Admin-only; never exposed via publicSettings.
|
||||||
|
'timezone',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED_BANK_FIELDS = [
|
||||||
|
'label',
|
||||||
|
'account_holder',
|
||||||
|
'iban',
|
||||||
|
'bic',
|
||||||
|
'currency',
|
||||||
|
'is_default',
|
||||||
|
'display_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALID_QR_FORMATS = new Set(['swiss', 'epc', 'none']);
|
||||||
|
|
||||||
|
function pickFields(payload, allowed) {
|
||||||
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
const out = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, key)) {
|
||||||
|
out[key] = payload[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseIban(iban) {
|
||||||
|
if (!iban) return iban;
|
||||||
|
return String(iban).replace(/\s+/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseCurrency(currency) {
|
||||||
|
if (!currency) return currency;
|
||||||
|
return String(currency).trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseCountryCode(cc) {
|
||||||
|
if (!cc) return cc;
|
||||||
|
return String(cc).trim().toUpperCase().slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitiseProfilePayload(payload) {
|
||||||
|
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
|
||||||
|
|
||||||
|
if (updates.country_code !== undefined) {
|
||||||
|
updates.country_code = normaliseCountryCode(updates.country_code);
|
||||||
|
}
|
||||||
|
if (updates.default_currency !== undefined) {
|
||||||
|
updates.default_currency = normaliseCurrency(updates.default_currency);
|
||||||
|
}
|
||||||
|
if (updates.default_qr_format !== undefined) {
|
||||||
|
const v = String(updates.default_qr_format || '').trim().toLowerCase();
|
||||||
|
updates.default_qr_format = VALID_QR_FORMATS.has(v) ? v : 'none';
|
||||||
|
}
|
||||||
|
// Trim free-text fields to avoid silent leading/trailing whitespace
|
||||||
|
// when the admin pastes from a printed letterhead.
|
||||||
|
for (const field of ['company_name', 'address_line1', 'address_line2',
|
||||||
|
'city', 'state', 'country_name', 'phone', 'mobile', 'email', 'website',
|
||||||
|
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path']) {
|
||||||
|
if (typeof updates[field] === 'string') {
|
||||||
|
updates[field] = updates[field].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalise the boolean PDF visibility toggles. Empty / undefined
|
||||||
|
// stays untouched (so partial updates don't reset existing values).
|
||||||
|
for (const field of [
|
||||||
|
'pdf_show_logo', 'pdf_show_company_name', 'pdf_company_name_inline',
|
||||||
|
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
|
||||||
|
]) {
|
||||||
|
if (updates[field] !== undefined) {
|
||||||
|
updates[field] = formatBoolean(Boolean(updates[field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Folding-mark enum — whitelisted set. Garbage values fall back to
|
||||||
|
// 'none' so a typo can't shoot itself in the foot.
|
||||||
|
if (updates.pdf_folding_marks !== undefined) {
|
||||||
|
const v = String(updates.pdf_folding_marks || '').toLowerCase();
|
||||||
|
updates.pdf_folding_marks = ['none', 'half', 'third', 'both'].includes(v) ? v : 'none';
|
||||||
|
}
|
||||||
|
// Logo height — clamp to a sensible range (24-200pt). Out-of-range
|
||||||
|
// values get snapped instead of rejected so the form can be lax.
|
||||||
|
if (updates.pdf_logo_height !== undefined) {
|
||||||
|
const n = parseInt(updates.pdf_logo_height, 10);
|
||||||
|
updates.pdf_logo_height = Number.isFinite(n)
|
||||||
|
? Math.max(24, Math.min(200, n))
|
||||||
|
: 56;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitiseBankPayload(payload) {
|
||||||
|
const updates = pickFields(payload, ALLOWED_BANK_FIELDS);
|
||||||
|
|
||||||
|
if (updates.iban !== undefined) {
|
||||||
|
updates.iban = normaliseIban(updates.iban);
|
||||||
|
}
|
||||||
|
if (updates.bic !== undefined && typeof updates.bic === 'string') {
|
||||||
|
updates.bic = updates.bic.replace(/\s+/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
if (updates.currency !== undefined) {
|
||||||
|
updates.currency = normaliseCurrency(updates.currency);
|
||||||
|
}
|
||||||
|
if (updates.is_default !== undefined) {
|
||||||
|
updates.is_default = formatBoolean(Boolean(updates.is_default));
|
||||||
|
}
|
||||||
|
for (const field of ['label', 'account_holder']) {
|
||||||
|
if (typeof updates[field] === 'string') {
|
||||||
|
updates[field] = updates[field].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the singleton business_profile row + its bank accounts.
|
||||||
|
* Always returns a profile object even if the row is empty — the
|
||||||
|
* Settings UI binds straight to this shape.
|
||||||
|
*/
|
||||||
|
async function getProfile() {
|
||||||
|
return await withRetry(async () => {
|
||||||
|
let profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
if (!profile) {
|
||||||
|
// Belt-and-braces: migration 102 seeds id=1, but if a fresh install
|
||||||
|
// ran an earlier rollback that wiped the row, re-create it so the
|
||||||
|
// service never throws.
|
||||||
|
await db('business_profile').insert({ id: 1 });
|
||||||
|
profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await db('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1 })
|
||||||
|
.orderBy('display_order', 'asc')
|
||||||
|
.orderBy('id', 'asc');
|
||||||
|
|
||||||
|
return { profile, bankAccounts: accounts };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(payload, adminId) {
|
||||||
|
const updates = sanitiseProfilePayload(payload);
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return await getProfile();
|
||||||
|
}
|
||||||
|
updates.updated_at = new Date();
|
||||||
|
|
||||||
|
await withRetry(async () => {
|
||||||
|
await db('business_profile').where({ id: 1 }).update(updates);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Business profile updated', {
|
||||||
|
adminId,
|
||||||
|
fields: Object.keys(updates).filter((k) => k !== 'updated_at'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new bank account. If `is_default = true`, atomically clear
|
||||||
|
* the default flag on every other account in the same currency.
|
||||||
|
*/
|
||||||
|
async function createBankAccount(payload, adminId) {
|
||||||
|
const data = sanitiseBankPayload(payload);
|
||||||
|
if (!data.iban) {
|
||||||
|
throw new AppError('iban is required', 400);
|
||||||
|
}
|
||||||
|
data.business_profile_id = 1;
|
||||||
|
data.created_at = new Date();
|
||||||
|
data.updated_at = new Date();
|
||||||
|
// Default off when not specified — we don't want the first account
|
||||||
|
// accidentally becoming default just because the form omitted the field.
|
||||||
|
if (data.is_default === undefined) data.is_default = formatBoolean(false);
|
||||||
|
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
if (data.is_default && (data.is_default === true || data.is_default === 1)) {
|
||||||
|
await trx('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1, currency: data.currency })
|
||||||
|
.update({ is_default: formatBoolean(false), updated_at: new Date() });
|
||||||
|
}
|
||||||
|
const inserted = await trx('business_bank_accounts').insert(data).returning('id');
|
||||||
|
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
logger.info('Business bank account created', {
|
||||||
|
adminId, id, iban: data.iban?.slice(-4), currency: data.currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await trx('business_bank_accounts').where({ id }).first();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBankAccount(id, payload, adminId) {
|
||||||
|
const data = sanitiseBankPayload(payload);
|
||||||
|
data.updated_at = new Date();
|
||||||
|
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
const existing = await trx('business_bank_accounts').where({ id }).first();
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError('Bank account not found', 404);
|
||||||
|
}
|
||||||
|
// Honour the per-currency single-default rule.
|
||||||
|
if (data.is_default === true || data.is_default === 1 || data.is_default === formatBoolean(true)) {
|
||||||
|
const targetCurrency = data.currency || existing.currency;
|
||||||
|
await trx('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1, currency: targetCurrency })
|
||||||
|
.andWhereNot({ id })
|
||||||
|
.update({ is_default: formatBoolean(false), updated_at: new Date() });
|
||||||
|
}
|
||||||
|
await trx('business_bank_accounts').where({ id }).update(data);
|
||||||
|
|
||||||
|
logger.info('Business bank account updated', { adminId, id });
|
||||||
|
|
||||||
|
return await trx('business_bank_accounts').where({ id }).first();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBankAccount(id, adminId) {
|
||||||
|
return await withRetry(async () => {
|
||||||
|
const existing = await db('business_bank_accounts').where({ id }).first();
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError('Bank account not found', 404);
|
||||||
|
}
|
||||||
|
await db('business_bank_accounts').where({ id }).del();
|
||||||
|
logger.info('Business bank account deleted', { adminId, id });
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the bank account that should print on a quote/invoice for a
|
||||||
|
* given currency: explicit override → default for that currency →
|
||||||
|
* default for the profile's default_currency → first by display_order.
|
||||||
|
*/
|
||||||
|
async function resolveBankAccountForCurrency(currency, overrideId = null) {
|
||||||
|
return await withRetry(async () => {
|
||||||
|
if (overrideId) {
|
||||||
|
const explicit = await db('business_bank_accounts').where({ id: overrideId }).first();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
}
|
||||||
|
if (currency) {
|
||||||
|
const match = await db('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1, currency, is_default: formatBoolean(true) })
|
||||||
|
.first();
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
const anyDefault = await db('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1, is_default: formatBoolean(true) })
|
||||||
|
.first();
|
||||||
|
if (anyDefault) return anyDefault;
|
||||||
|
return await db('business_bank_accounts')
|
||||||
|
.where({ business_profile_id: 1 })
|
||||||
|
.orderBy('display_order', 'asc').orderBy('id', 'asc').first();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getProfile,
|
||||||
|
updateProfile,
|
||||||
|
createBankAccount,
|
||||||
|
updateBankAccount,
|
||||||
|
deleteBankAccount,
|
||||||
|
resolveBankAccountForCurrency,
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* contractBlocksService — CRUD for the contract block library.
|
||||||
|
*
|
||||||
|
* The library is shared across all contracts. System blocks (12 seeded
|
||||||
|
* by migration 130) cannot be deleted but their body text remains
|
||||||
|
* editable so the admin's lawyer can rewrite them. Admin-authored
|
||||||
|
* (non-system) blocks can be freely created, edited, and removed.
|
||||||
|
*
|
||||||
|
* Sections are validated against a fixed enum mirroring
|
||||||
|
* contractService.SECTIONS_ORDER — keeping these in sync is the
|
||||||
|
* "data-driven all the way down" guarantee (no orphan sections in
|
||||||
|
* the DB that the renderer can't display).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db, withRetry } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
|
|
||||||
|
const ALLOWED_SECTIONS = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System blocks added to the seed AFTER migration 131 was already
|
||||||
|
* deployed to beta. knex won't re-run an already-applied migration,
|
||||||
|
* so the new system blocks listed here need a runtime self-heal —
|
||||||
|
* same pattern as `ensureContractEmailTemplatesSeeded` for templates.
|
||||||
|
*
|
||||||
|
* Each entry must carry every column the row needs. `slug` is the
|
||||||
|
* uniqueness key; the seeder is no-op when the row already exists.
|
||||||
|
* EN/DE bodies only — non-EN/DE locales stay null until the admin
|
||||||
|
* fills them in via the block library UI.
|
||||||
|
*/
|
||||||
|
const RUNTIME_SEEDED_BLOCKS = [
|
||||||
|
{
|
||||||
|
slug: 'quote_line_items_table',
|
||||||
|
section: 'scope',
|
||||||
|
name: 'Quote line items',
|
||||||
|
description: 'Auto-inserts the source quote\'s line items as a table. Body text appears above the table.',
|
||||||
|
body_text: 'Service items per quote {{source_quote_number}}:',
|
||||||
|
body_text_de: 'Leistungspositionen gemäß Angebot {{source_quote_number}}:',
|
||||||
|
is_system: true,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Module-scope flag so the seed check runs once per process. The
|
||||||
|
// underlying queries are still idempotent — this just saves the round
|
||||||
|
// trip on every listBlocks / createContract call.
|
||||||
|
let _systemBlocksSeeded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-heal: insert any RUNTIME_SEEDED_BLOCKS entries that don't yet
|
||||||
|
* exist in contract_blocks. Called from listBlocks + createContract
|
||||||
|
* paths so new system blocks appear automatically on installs that
|
||||||
|
* applied an earlier version of migration 131. Idempotent.
|
||||||
|
*/
|
||||||
|
async function ensureSystemBlocksSeeded() {
|
||||||
|
if (_systemBlocksSeeded) return [];
|
||||||
|
if (!(await db.schema.hasTable('contract_blocks'))) return [];
|
||||||
|
const newlyInserted = [];
|
||||||
|
for (const def of RUNTIME_SEEDED_BLOCKS) {
|
||||||
|
try {
|
||||||
|
const existing = await db('contract_blocks').where({ slug: def.slug }).first();
|
||||||
|
if (existing) continue;
|
||||||
|
// display_order = current MAX in the target section + 1 so the
|
||||||
|
// new block sorts to the end. Matches the migration's behaviour.
|
||||||
|
const maxOrderRow = await db('contract_blocks')
|
||||||
|
.where({ section: def.section })
|
||||||
|
.max('display_order as max').first();
|
||||||
|
const nextOrder = (maxOrderRow?.max || 0) + 1;
|
||||||
|
await db('contract_blocks').insert({
|
||||||
|
...def,
|
||||||
|
display_order: nextOrder,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
newlyInserted.push(def.slug);
|
||||||
|
logger.info(`Self-healed missing system contract block at runtime: ${def.slug}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to seed system contract block ${def.slug}`, { message: err.message });
|
||||||
|
// Keep _systemBlocksSeeded=false so the next call retries.
|
||||||
|
return newlyInserted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_systemBlocksSeeded = true;
|
||||||
|
return newlyInserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSection(section) {
|
||||||
|
if (!ALLOWED_SECTIONS.includes(section)) {
|
||||||
|
throw new AppError(
|
||||||
|
`Invalid section '${section}'. Must be one of: ${ALLOWED_SECTIONS.join(', ')}`,
|
||||||
|
400,
|
||||||
|
'INVALID_SECTION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
const base = String(name || 'block')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
// Append a 6-hex suffix so admin-authored blocks don't collide with
|
||||||
|
// each other or with seeded slugs.
|
||||||
|
const suffix = require('crypto').randomBytes(3).toString('hex');
|
||||||
|
return `${base || 'block'}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBlocks({ section, includeInactive = false } = {}) {
|
||||||
|
// Self-heal before reading so the new system block (added to the
|
||||||
|
// already-deployed migration 131 in feat/crm) appears in the
|
||||||
|
// library UI immediately on first GET, without needing a fresh
|
||||||
|
// install. Safe to call repeatedly — guarded by _systemBlocksSeeded.
|
||||||
|
await ensureSystemBlocksSeeded();
|
||||||
|
return await withRetry(async () => {
|
||||||
|
let q = db('contract_blocks').select('*');
|
||||||
|
if (section) q = q.where({ section });
|
||||||
|
if (!includeInactive) q = q.where({ is_active: true });
|
||||||
|
q = q.orderBy('section', 'asc').orderBy('display_order', 'asc').orderBy('id', 'asc');
|
||||||
|
return await q;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBlockById(id) {
|
||||||
|
return await db('contract_blocks').where({ id }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBlock(payload) {
|
||||||
|
if (!payload.name || !String(payload.name).trim()) {
|
||||||
|
throw new AppError('Block name is required', 400);
|
||||||
|
}
|
||||||
|
ensureSection(payload.section);
|
||||||
|
if (!payload.bodyText || !String(payload.bodyText).trim()) {
|
||||||
|
throw new AppError('Block body (EN) is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = payload.slug && /^[a-z0-9_]+$/.test(payload.slug)
|
||||||
|
? payload.slug
|
||||||
|
: slugify(payload.name);
|
||||||
|
|
||||||
|
// Ensure slug uniqueness (regenerate on the rare collision).
|
||||||
|
let finalSlug = slug;
|
||||||
|
let attempt = 0;
|
||||||
|
while (await db('contract_blocks').where({ slug: finalSlug }).first()) {
|
||||||
|
attempt += 1;
|
||||||
|
finalSlug = slugify(payload.name);
|
||||||
|
if (attempt > 5) {
|
||||||
|
throw new AppError('Could not generate a unique block slug', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
slug: finalSlug,
|
||||||
|
section: payload.section,
|
||||||
|
name: String(payload.name).trim().slice(0, 128),
|
||||||
|
description: payload.description ? String(payload.description).slice(0, 255) : null,
|
||||||
|
body_text: String(payload.bodyText),
|
||||||
|
body_text_de: payload.bodyTextDe ? String(payload.bodyTextDe) : null,
|
||||||
|
is_system: false,
|
||||||
|
is_active: payload.isActive !== false,
|
||||||
|
display_order: Number.isFinite(payload.displayOrder) ? Number(payload.displayOrder) : 100,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
};
|
||||||
|
// Schema-drift guard — migration 131 adds these columns. On installs
|
||||||
|
// that haven't migrated yet, only EN+DE bodies persist; the other
|
||||||
|
// four fields are accepted from the payload but silently dropped.
|
||||||
|
for (const [field, payloadKey] of [
|
||||||
|
['body_text_ru', 'bodyTextRu'],
|
||||||
|
['body_text_pt', 'bodyTextPt'],
|
||||||
|
['body_text_nl', 'bodyTextNl'],
|
||||||
|
['body_text_fr', 'bodyTextFr'],
|
||||||
|
]) {
|
||||||
|
if (payload[payloadKey] != null
|
||||||
|
&& await hasColumnCached('contract_blocks', field)) {
|
||||||
|
row[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db('contract_blocks').insert(row).returning('id');
|
||||||
|
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
return await getBlockById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a block. System blocks: every field is editable so the
|
||||||
|
* admin's lawyer can rewrite the body text in place. The only
|
||||||
|
* protection on system blocks is that they can't be hard-deleted —
|
||||||
|
* an admin who wants to retire one toggles `is_active=false`.
|
||||||
|
*/
|
||||||
|
async function updateBlock(id, payload) {
|
||||||
|
const existing = await getBlockById(id);
|
||||||
|
if (!existing) throw new AppError('Block not found', 404);
|
||||||
|
|
||||||
|
const updates = { updated_at: new Date() };
|
||||||
|
if ('section' in payload) {
|
||||||
|
ensureSection(payload.section);
|
||||||
|
updates.section = payload.section;
|
||||||
|
}
|
||||||
|
if ('name' in payload) {
|
||||||
|
if (!payload.name || !String(payload.name).trim()) {
|
||||||
|
throw new AppError('Block name is required', 400);
|
||||||
|
}
|
||||||
|
updates.name = String(payload.name).trim().slice(0, 128);
|
||||||
|
}
|
||||||
|
if ('description' in payload) {
|
||||||
|
updates.description = payload.description ? String(payload.description).slice(0, 255) : null;
|
||||||
|
}
|
||||||
|
if ('bodyText' in payload) {
|
||||||
|
if (!payload.bodyText || !String(payload.bodyText).trim()) {
|
||||||
|
throw new AppError('Block body (EN) is required', 400);
|
||||||
|
}
|
||||||
|
updates.body_text = String(payload.bodyText);
|
||||||
|
}
|
||||||
|
if ('bodyTextDe' in payload) {
|
||||||
|
updates.body_text_de = payload.bodyTextDe ? String(payload.bodyTextDe) : null;
|
||||||
|
}
|
||||||
|
// Same schema-drift guard as createBlock — accept ru/pt/nl/fr only
|
||||||
|
// when the column actually exists, so beta installs running this
|
||||||
|
// service against a not-yet-migrated DB don't throw.
|
||||||
|
for (const [field, payloadKey] of [
|
||||||
|
['body_text_ru', 'bodyTextRu'],
|
||||||
|
['body_text_pt', 'bodyTextPt'],
|
||||||
|
['body_text_nl', 'bodyTextNl'],
|
||||||
|
['body_text_fr', 'bodyTextFr'],
|
||||||
|
]) {
|
||||||
|
if (payloadKey in payload
|
||||||
|
&& await hasColumnCached('contract_blocks', field)) {
|
||||||
|
updates[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('isActive' in payload) {
|
||||||
|
updates.is_active = payload.isActive !== false;
|
||||||
|
}
|
||||||
|
if ('displayOrder' in payload && Number.isFinite(payload.displayOrder)) {
|
||||||
|
updates.display_order = Number(payload.displayOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('contract_blocks').where({ id }).update(updates);
|
||||||
|
return await getBlockById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-delete an admin-authored block. System blocks refuse delete —
|
||||||
|
* the admin must `deactivate` (toggle `is_active=false`) instead.
|
||||||
|
*
|
||||||
|
* Active inclusions on existing contracts are protected by the FK
|
||||||
|
* ON DELETE RESTRICT — deleting a block that's still referenced will
|
||||||
|
* raise a DB error which we catch and surface as a clean 409.
|
||||||
|
*/
|
||||||
|
async function deleteBlock(id) {
|
||||||
|
const existing = await getBlockById(id);
|
||||||
|
if (!existing) throw new AppError('Block not found', 404);
|
||||||
|
if (existing.is_system) {
|
||||||
|
throw new AppError(
|
||||||
|
'System blocks cannot be deleted. Toggle them inactive instead so they remain available for audit on old contracts.',
|
||||||
|
409,
|
||||||
|
'SYSTEM_BLOCK_PROTECTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db('contract_blocks').where({ id }).del();
|
||||||
|
} catch (err) {
|
||||||
|
if (/foreign key|FOREIGN KEY|RESTRICT/i.test(err.message)) {
|
||||||
|
throw new AppError(
|
||||||
|
'This block is referenced by one or more contracts. Toggle it inactive instead of deleting.',
|
||||||
|
409,
|
||||||
|
'BLOCK_IN_USE',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listBlocks,
|
||||||
|
getBlockById,
|
||||||
|
createBlock,
|
||||||
|
updateBlock,
|
||||||
|
deleteBlock,
|
||||||
|
ensureSystemBlocksSeeded,
|
||||||
|
ALLOWED_SECTIONS,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,12 @@ const PREFILLABLE_FIELDS = [
|
|||||||
'city',
|
'city',
|
||||||
'state',
|
'state',
|
||||||
'country_code',
|
'country_code',
|
||||||
|
'country_name',
|
||||||
|
// Locale used for portal UI AND for quote/invoice PDF rendering.
|
||||||
|
// Admin can pre-set this on the invitation so a German customer
|
||||||
|
// gets German documents from the very first invoice, without
|
||||||
|
// waiting for them to log in and pick their language.
|
||||||
|
'preferred_language',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,9 +107,14 @@ async function createInvitation({ email, invitedById, prefill }) {
|
|||||||
const existingCustomer = await db('customer_accounts')
|
const existingCustomer = await db('customer_accounts')
|
||||||
.where('email', normalisedEmail)
|
.where('email', normalisedEmail)
|
||||||
.first();
|
.first();
|
||||||
if (existingCustomer) {
|
if (existingCustomer && existingCustomer.password_hash) {
|
||||||
|
// Already-active customer with this email — duplicate, reject.
|
||||||
throw new ConflictError('A customer account with this email already exists', 'email');
|
throw new ConflictError('A customer account with this email already exists', 'email');
|
||||||
}
|
}
|
||||||
|
// If the existing customer is PASSIVE (password_hash IS NULL), this
|
||||||
|
// is the "promote to active" path: the admin clicked "Send portal
|
||||||
|
// invitation" on a passive customer. Allow the invitation through —
|
||||||
|
// acceptInvitation handles the UPSERT into the existing row.
|
||||||
|
|
||||||
const pendingInvite = await db('customer_invitations')
|
const pendingInvite = await db('customer_invitations')
|
||||||
.where('email', normalisedEmail)
|
.where('email', normalisedEmail)
|
||||||
@@ -158,6 +169,92 @@ async function createInvitation({ email, invitedById, prefill }) {
|
|||||||
return { id, email: normalisedEmail, token, expiresAt };
|
return { id, email: normalisedEmail, token, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a "passive" customer directly — no invitation, no email.
|
||||||
|
*
|
||||||
|
* Used for two flows:
|
||||||
|
* 1. Admin opens the quote/invoice editor, clicks "+ Create new
|
||||||
|
* customer", fills out the form, hits "Save as passive customer".
|
||||||
|
* The customer becomes available immediately as the recipient of
|
||||||
|
* the document the admin is working on.
|
||||||
|
* 2. Admin opens the same form and hits "Save & send portal
|
||||||
|
* invitation". The editor calls createDirect first to mint the
|
||||||
|
* customer id, then calls the send-invite route to fire the
|
||||||
|
* onboarding email. (Two separate API calls — easier to reason
|
||||||
|
* about than an atomic endpoint.)
|
||||||
|
*
|
||||||
|
* A passive customer is identified by `password_hash IS NULL`. The
|
||||||
|
* customerAuth middleware already rejects login for those (bcrypt
|
||||||
|
* compare against null returns false), so we don't need a separate
|
||||||
|
* "is_passive" column or an extra gate.
|
||||||
|
*
|
||||||
|
* Race-guarded against duplicate emails the same way createInvitation
|
||||||
|
* is — a real duplicate throws ConflictError.
|
||||||
|
*
|
||||||
|
* @param {{ email, prefill, createdByAdminId }} args
|
||||||
|
* @returns {Promise<{ id }>} The new customer's id.
|
||||||
|
*/
|
||||||
|
async function createDirect({ email, prefill, createdByAdminId }) {
|
||||||
|
const normalisedEmail = String(email || '').trim().toLowerCase();
|
||||||
|
if (!normalisedEmail) throw new ValidationError('Email is required');
|
||||||
|
|
||||||
|
const existing = await db('customer_accounts')
|
||||||
|
.where('email', normalisedEmail)
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('A customer account with this email already exists', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same default-locale resolution as acceptInvitation so German
|
||||||
|
// shops get German customers automatically.
|
||||||
|
let defaultPreferredLanguage = 'en';
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const businessProfileService = require('./businessProfileService');
|
||||||
|
const { profile: bp } = await businessProfileService.getProfile();
|
||||||
|
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
|
||||||
|
} catch (_) { /* keep 'en' fallback */ }
|
||||||
|
|
||||||
|
const sanitised = sanitisePrefill(prefill) || {};
|
||||||
|
const preferredLanguage = sanitised.preferred_language || defaultPreferredLanguage;
|
||||||
|
|
||||||
|
const [inserted] = await db('customer_accounts').insert({
|
||||||
|
email: normalisedEmail,
|
||||||
|
salutation: sanitised.salutation || null,
|
||||||
|
first_name: sanitised.first_name || null,
|
||||||
|
last_name: sanitised.last_name || null,
|
||||||
|
display_name: sanitised.display_name || null,
|
||||||
|
phone: sanitised.phone || null,
|
||||||
|
company_name: sanitised.company_name || null,
|
||||||
|
vat_id: sanitised.vat_id || null,
|
||||||
|
address_line1: sanitised.address_line1 || null,
|
||||||
|
address_line2: sanitised.address_line2 || null,
|
||||||
|
postal_code: sanitised.postal_code || null,
|
||||||
|
city: sanitised.city || null,
|
||||||
|
state: sanitised.state || null,
|
||||||
|
country_code: sanitised.country_code || null,
|
||||||
|
country_name: sanitised.country_name || null,
|
||||||
|
preferred_language: preferredLanguage,
|
||||||
|
password_hash: null,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
must_change_password: formatBoolean(false),
|
||||||
|
password_changed_at: null,
|
||||||
|
created_by_admin_id: createdByAdminId || null,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
const id = inserted?.id || inserted;
|
||||||
|
|
||||||
|
await logActivity('customer_created_passive',
|
||||||
|
{ customerId: id, email: normalisedEmail },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: createdByAdminId || null, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invitation. Creates the customer_accounts row in a transaction
|
* Accept an invitation. Creates the customer_accounts row in a transaction
|
||||||
* and marks the invitation accepted, so a partial failure can't leave a
|
* and marks the invitation accepted, so a partial failure can't leave a
|
||||||
@@ -174,14 +271,23 @@ async function acceptInvitation({ token, name, password, profile }) {
|
|||||||
throw new ValidationError('Invalid or expired invitation');
|
throw new ValidationError('Invalid or expired invitation');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Race-condition guard: an admin may have created the customer manually
|
// Race-condition guard: an admin may have created the customer
|
||||||
// (future flow) between the invite link being generated and clicked.
|
// manually (passive customer flow, migration-119-era and later)
|
||||||
|
// between the invite link being generated and clicked.
|
||||||
|
//
|
||||||
|
// Two cases:
|
||||||
|
// - existing.password_hash IS NOT NULL → real duplicate, 409
|
||||||
|
// - existing.password_hash IS NULL → passive customer being
|
||||||
|
// promoted to active. Branch to the UPSERT path further down so
|
||||||
|
// the customer's id (and all the rows that reference it —
|
||||||
|
// invoices, quotes, gallery assignments) survive promotion.
|
||||||
const existing = await db('customer_accounts')
|
const existing = await db('customer_accounts')
|
||||||
.where('email', invitation.email)
|
.where('email', invitation.email)
|
||||||
.first();
|
.first();
|
||||||
if (existing) {
|
if (existing && existing.password_hash) {
|
||||||
throw new ConflictError('Email already registered', 'email');
|
throw new ConflictError('Email already registered', 'email');
|
||||||
}
|
}
|
||||||
|
const promoting = !!existing && !existing.password_hash;
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||||
|
|
||||||
@@ -200,47 +306,105 @@ async function acceptInvitation({ token, name, password, profile }) {
|
|||||||
merged.display_name = String(name).trim();
|
merged.display_name = String(name).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default the customer's preferred_language to the business profile's
|
||||||
|
// default_locale. Migration 090 sets the schema default to 'en' which
|
||||||
|
// is a poor fit for a Swiss/DE business — by pulling from the
|
||||||
|
// configured profile we make sure German shops issue German quotes
|
||||||
|
// and invoices to their new customers automatically. Customer-typed
|
||||||
|
// value still wins (if the accept form ever exposes the picker), and
|
||||||
|
// the admin can always override later on the customer detail page.
|
||||||
|
// Lazy require to avoid a service-cycle with businessProfileService.
|
||||||
|
let defaultPreferredLanguage = 'en';
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const businessProfileService = require('./businessProfileService');
|
||||||
|
const { profile: bp } = await businessProfileService.getProfile();
|
||||||
|
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
|
||||||
|
} catch (_) { /* keep 'en' fallback */ }
|
||||||
|
const preferredLanguage = merged.preferred_language || defaultPreferredLanguage;
|
||||||
|
|
||||||
const customerId = await db.transaction(async (trx) => {
|
const customerId = await db.transaction(async (trx) => {
|
||||||
const [inserted] = await trx('customer_accounts').insert({
|
let id;
|
||||||
email: invitation.email,
|
if (promoting) {
|
||||||
// Profile fields land directly on the customer row. Anything the user
|
// Promotion path: passive customer being claimed by the
|
||||||
// didn't set stays null.
|
// customer themselves via the invitation link. UPDATE the
|
||||||
salutation: merged.salutation || null,
|
// existing row (preserving id + all foreign-key relationships)
|
||||||
first_name: merged.first_name || null,
|
// instead of inserting. We merge the profile fields: anything
|
||||||
last_name: merged.last_name || null,
|
// the customer typed on the accept form wins; values they
|
||||||
display_name: merged.display_name || null,
|
// didn't touch leave the existing row untouched.
|
||||||
phone: merged.phone || null,
|
id = existing.id;
|
||||||
company_name: merged.company_name || null,
|
const updates = {
|
||||||
vat_id: merged.vat_id || null,
|
password_hash: passwordHash,
|
||||||
address_line1: merged.address_line1 || null,
|
password_changed_at: null,
|
||||||
address_line2: merged.address_line2 || null,
|
is_active: formatBoolean(true),
|
||||||
postal_code: merged.postal_code || null,
|
must_change_password: formatBoolean(false),
|
||||||
city: merged.city || null,
|
updated_at: new Date(),
|
||||||
state: merged.state || null,
|
};
|
||||||
country_code: merged.country_code || null,
|
// Only overwrite profile fields when the merged payload
|
||||||
password_hash: passwordHash,
|
// actually carries a value — never blank out existing data
|
||||||
is_active: formatBoolean(true),
|
// (the customer might have left a field empty because the
|
||||||
// must_change_password is decorative today — accept-invite always
|
// admin had pre-filled it correctly).
|
||||||
// sets a customer-chosen password, so this flag is never true and
|
const overwriteIfSet = (key, col = key) => {
|
||||||
// customerAuth doesn't read it. TODO when we ship an "admin
|
if (merged[key] != null && merged[key] !== '') updates[col] = merged[key];
|
||||||
// pre-loads a temporary password" flow: surface a code in the
|
};
|
||||||
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
|
overwriteIfSet('salutation');
|
||||||
// add a /change-password gate to customerAuth.
|
overwriteIfSet('first_name');
|
||||||
must_change_password: formatBoolean(false),
|
overwriteIfSet('last_name');
|
||||||
// Leave password_changed_at NULL on initial accept. Setting it here
|
overwriteIfSet('display_name');
|
||||||
// creates a millisecond/second-rounding race with the JWT issued
|
overwriteIfSet('phone');
|
||||||
// by the immediate /login call: stored timestamp X.500ms can floor
|
overwriteIfSet('company_name');
|
||||||
// to X+1 in postgres while the JWT's iat lands at X, causing the
|
overwriteIfSet('vat_id');
|
||||||
// customerAuth middleware's `iat < password_changed_at` check to
|
overwriteIfSet('address_line1');
|
||||||
// reject perfectly valid tokens on the very next page reload. We
|
overwriteIfSet('address_line2');
|
||||||
// populate password_changed_at only when an actual password change
|
overwriteIfSet('postal_code');
|
||||||
// happens later (deactivate / reset flows).
|
overwriteIfSet('city');
|
||||||
password_changed_at: null,
|
overwriteIfSet('state');
|
||||||
created_by_admin_id: invitation.invited_by,
|
overwriteIfSet('country_code');
|
||||||
created_at: new Date(),
|
if (merged.preferred_language) updates.preferred_language = merged.preferred_language;
|
||||||
updated_at: new Date(),
|
await trx('customer_accounts').where('id', id).update(updates);
|
||||||
}).returning('id');
|
} else {
|
||||||
const id = inserted?.id || inserted;
|
const [inserted] = await trx('customer_accounts').insert({
|
||||||
|
email: invitation.email,
|
||||||
|
// Profile fields land directly on the customer row. Anything the user
|
||||||
|
// didn't set stays null.
|
||||||
|
salutation: merged.salutation || null,
|
||||||
|
first_name: merged.first_name || null,
|
||||||
|
last_name: merged.last_name || null,
|
||||||
|
display_name: merged.display_name || null,
|
||||||
|
phone: merged.phone || null,
|
||||||
|
company_name: merged.company_name || null,
|
||||||
|
vat_id: merged.vat_id || null,
|
||||||
|
address_line1: merged.address_line1 || null,
|
||||||
|
address_line2: merged.address_line2 || null,
|
||||||
|
postal_code: merged.postal_code || null,
|
||||||
|
city: merged.city || null,
|
||||||
|
state: merged.state || null,
|
||||||
|
country_code: merged.country_code || null,
|
||||||
|
preferred_language: preferredLanguage,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
// must_change_password is decorative today — accept-invite always
|
||||||
|
// sets a customer-chosen password, so this flag is never true and
|
||||||
|
// customerAuth doesn't read it. TODO when we ship an "admin
|
||||||
|
// pre-loads a temporary password" flow: surface a code in the
|
||||||
|
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
|
||||||
|
// add a /change-password gate to customerAuth.
|
||||||
|
must_change_password: formatBoolean(false),
|
||||||
|
// Leave password_changed_at NULL on initial accept. Setting it here
|
||||||
|
// creates a millisecond/second-rounding race with the JWT issued
|
||||||
|
// by the immediate /login call: stored timestamp X.500ms can floor
|
||||||
|
// to X+1 in postgres while the JWT's iat lands at X, causing the
|
||||||
|
// customerAuth middleware's `iat < password_changed_at` check to
|
||||||
|
// reject perfectly valid tokens on the very next page reload. We
|
||||||
|
// populate password_changed_at only when an actual password change
|
||||||
|
// happens later (deactivate / reset flows).
|
||||||
|
password_changed_at: null,
|
||||||
|
created_by_admin_id: invitation.invited_by,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
id = inserted?.id || inserted;
|
||||||
|
}
|
||||||
|
|
||||||
await trx('customer_invitations')
|
await trx('customer_invitations')
|
||||||
.where('id', invitation.id)
|
.where('id', invitation.id)
|
||||||
@@ -301,6 +465,22 @@ async function listCustomers({ search } = {}) {
|
|||||||
'customer_accounts.salutation',
|
'customer_accounts.salutation',
|
||||||
'customer_accounts.company_name',
|
'customer_accounts.company_name',
|
||||||
'customer_accounts.is_active',
|
'customer_accounts.is_active',
|
||||||
|
// Surfaced so the route's transformCustomer can compute the
|
||||||
|
// `isPassive` flag (passwordHash == null). The actual hash
|
||||||
|
// never leaves the API — transformCustomer drops it.
|
||||||
|
'customer_accounts.password_hash',
|
||||||
|
// Per-customer feature flags + hourly rate (migrations 092/129).
|
||||||
|
// Surfaced on the LIST endpoint so the standalone Hours-logging
|
||||||
|
// page can filter the customer dropdown to only customers with
|
||||||
|
// hours logging enabled, and read the default rate without an
|
||||||
|
// N+1 detail fetch. Without these in the SELECT,
|
||||||
|
// transformCustomer evaluates the four feature_* booleans as
|
||||||
|
// false (column absent → undefined → coerce to false).
|
||||||
|
'customer_accounts.feature_calendar',
|
||||||
|
'customer_accounts.feature_quotes',
|
||||||
|
'customer_accounts.feature_bills',
|
||||||
|
'customer_accounts.feature_hours_logging',
|
||||||
|
'customer_accounts.hourly_rate_minor',
|
||||||
'customer_accounts.last_login',
|
'customer_accounts.last_login',
|
||||||
'customer_accounts.created_at',
|
'customer_accounts.created_at',
|
||||||
db.raw('COUNT(event_customer_assignments.id) as event_count')
|
db.raw('COUNT(event_customer_assignments.id) as event_count')
|
||||||
@@ -364,10 +544,17 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
|||||||
'email', 'salutation', 'first_name', 'last_name', 'display_name',
|
'email', 'salutation', 'first_name', 'last_name', 'display_name',
|
||||||
'phone', 'company_name', 'billing_email', 'vat_id',
|
'phone', 'company_name', 'billing_email', 'vat_id',
|
||||||
'address_line1', 'address_line2', 'postal_code', 'city', 'state',
|
'address_line1', 'address_line2', 'postal_code', 'city', 'state',
|
||||||
'country_code', 'preferred_language', 'notes',
|
'country_code', 'country_name', 'preferred_language', 'notes',
|
||||||
// Per-customer feature flags (#354 follow-up). Booleans below are
|
// Per-customer feature flags (#354 follow-up). Booleans below are
|
||||||
// coerced via formatBoolean for SQLite compatibility.
|
// coerced via formatBoolean for SQLite compatibility.
|
||||||
'feature_calendar', 'feature_quotes', 'feature_bills',
|
'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging',
|
||||||
|
// CRM billing cadence (migration 102). 'per_event' (default) keeps
|
||||||
|
// each invoice firing on its own schedule; monthly/quarterly snap
|
||||||
|
// every scheduled invoice to billing_cycle_day of the next period.
|
||||||
|
'billing_cadence', 'billing_cycle_day',
|
||||||
|
// Hour-logging default rate (migration 129). Minor units; null
|
||||||
|
// means admin must enter a per-entry override on every entry.
|
||||||
|
'hourly_rate_minor',
|
||||||
];
|
];
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
if (updates[f] !== undefined) {
|
if (updates[f] !== undefined) {
|
||||||
@@ -377,8 +564,45 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
|||||||
allowed[f] = String(updates[f] || '').trim().toLowerCase();
|
allowed[f] = String(updates[f] || '').trim().toLowerCase();
|
||||||
} else if (f === 'country_code' && updates[f]) {
|
} else if (f === 'country_code' && updates[f]) {
|
||||||
allowed[f] = String(updates[f]).trim().toUpperCase().slice(0, 2);
|
allowed[f] = String(updates[f]).trim().toUpperCase().slice(0, 2);
|
||||||
} else if (f === 'feature_calendar' || f === 'feature_quotes' || f === 'feature_bills') {
|
} else if (
|
||||||
|
f === 'feature_calendar' || f === 'feature_quotes'
|
||||||
|
|| f === 'feature_bills' || f === 'feature_hours_logging'
|
||||||
|
) {
|
||||||
allowed[f] = formatBoolean(updates[f]);
|
allowed[f] = formatBoolean(updates[f]);
|
||||||
|
} else if (f === 'hourly_rate_minor') {
|
||||||
|
// Default hourly rate. Null clears it (forces per-entry
|
||||||
|
// overrides); otherwise coerce to a non-negative bigint-safe
|
||||||
|
// integer. Anything funky → null.
|
||||||
|
if (updates[f] === null || updates[f] === '') {
|
||||||
|
allowed[f] = null;
|
||||||
|
} else {
|
||||||
|
const v = parseInt(updates[f], 10);
|
||||||
|
allowed[f] = Number.isFinite(v) && v >= 0 ? v : null;
|
||||||
|
}
|
||||||
|
} else if (f === 'billing_cadence') {
|
||||||
|
// Whitelist enum. Anything else flips to 'per_event' so we
|
||||||
|
// never persist garbage that the scheduler can't interpret.
|
||||||
|
const v = String(updates[f] || '').toLowerCase();
|
||||||
|
allowed[f] = ['per_event', 'monthly', 'quarterly'].includes(v) ? v : 'per_event';
|
||||||
|
} else if (f === 'billing_cycle_day') {
|
||||||
|
// Sign carries the interpretation:
|
||||||
|
// positive 1..28 → day-of-month (clamped to month length at
|
||||||
|
// schedule time, so cycleDay=28 stays valid
|
||||||
|
// in February)
|
||||||
|
// negative -1..-15 → that many days before end of month
|
||||||
|
// (cycleDay=-3 on a 31-day month fires on
|
||||||
|
// the 28th; on a 28-day February fires on
|
||||||
|
// the 25th)
|
||||||
|
// Zero is meaningless and clamps to 1 so the column never
|
||||||
|
// stores "the 0th of the month".
|
||||||
|
const v = parseInt(updates[f], 10);
|
||||||
|
if (!Number.isFinite(v) || v === 0) {
|
||||||
|
allowed[f] = 1;
|
||||||
|
} else if (v > 0) {
|
||||||
|
allowed[f] = Math.min(28, v);
|
||||||
|
} else {
|
||||||
|
allowed[f] = Math.max(-15, v);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
allowed[f] = updates[f];
|
allowed[f] = updates[f];
|
||||||
}
|
}
|
||||||
@@ -579,7 +803,22 @@ async function searchCustomers(query, { limit = 10 } = {}) {
|
|||||||
.orWhereRaw('LOWER(COALESCE(last_name, \'\')) LIKE ?', [term])
|
.orWhereRaw('LOWER(COALESCE(last_name, \'\')) LIKE ?', [term])
|
||||||
.orWhereRaw('LOWER(COALESCE(company_name, \'\')) LIKE ?', [term]);
|
.orWhereRaw('LOWER(COALESCE(company_name, \'\')) LIKE ?', [term]);
|
||||||
})
|
})
|
||||||
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'company_name')
|
// password_hash is required by transformCustomer to compute the
|
||||||
|
// isPassive flag (passwordHash == null = passive / admin-only).
|
||||||
|
// Omitting it caused every search result to render as "Passive —
|
||||||
|
// admin only" because `undefined == null` is true. The hash itself
|
||||||
|
// is dropped by the route's transformCustomer before leaving the API.
|
||||||
|
//
|
||||||
|
// G.2 — `feature_hours_logging` is required by the calendar's
|
||||||
|
// drag-create modal (F.6) so the CustomerPicker can render the
|
||||||
|
// "Hour logging disabled" badge. Omitting it from this SELECT
|
||||||
|
// caused the badge to appear on EVERY search result regardless
|
||||||
|
// of the actual per-customer flag, because transformCustomer
|
||||||
|
// coerces undefined → false.
|
||||||
|
.select(
|
||||||
|
'id', 'email', 'display_name', 'first_name', 'last_name', 'company_name',
|
||||||
|
'password_hash', 'feature_hours_logging',
|
||||||
|
)
|
||||||
.orderBy('email', 'asc')
|
.orderBy('email', 'asc')
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
@@ -970,10 +1209,25 @@ async function getCustomerSurfaceGlobals() {
|
|||||||
}
|
}
|
||||||
map[r.setting_key] = v;
|
map[r.setting_key] = v;
|
||||||
}
|
}
|
||||||
|
// Feature globals:
|
||||||
|
// - quotes + bills default TRUE — the customer-facing pages are
|
||||||
|
// fully built and the AND-logic with the per-customer flag is
|
||||||
|
// the real gate. The earlier hardcoded `false` made it
|
||||||
|
// impossible to surface the tabs without code changes.
|
||||||
|
// - calendar defaults FALSE — the customer-side page is still a
|
||||||
|
// coming-soon stub.
|
||||||
|
// Each is overridable via app_settings (setting_type='customer_surface').
|
||||||
|
const readBool = (key, fallback) => {
|
||||||
|
const v = map[key];
|
||||||
|
if (v === undefined) return fallback;
|
||||||
|
if (v === true || v === 1 || v === '1' || v === 't') return true;
|
||||||
|
if (v === false || v === 0 || v === '0' || v === 'f') return false;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
calendarEnabled: false,
|
calendarEnabled: readBool('customer_feature_calendar_enabled', false),
|
||||||
quotesEnabled: false,
|
quotesEnabled: readBool('customer_feature_quotes_enabled', true),
|
||||||
billsEnabled: false,
|
billsEnabled: readBool('customer_feature_bills_enabled', true),
|
||||||
showLogo: map.customer_show_logo !== false, // default true
|
showLogo: map.customer_show_logo !== false, // default true
|
||||||
showCompanyName: map.customer_show_company_name !== false, // default true
|
showCompanyName: map.customer_show_company_name !== false, // default true
|
||||||
};
|
};
|
||||||
@@ -994,13 +1248,32 @@ async function getEffectiveFeaturesForCustomer(customerOrId) {
|
|||||||
? await db('customer_accounts').where('id', customerOrId).first()
|
? await db('customer_accounts').where('id', customerOrId).first()
|
||||||
: customerOrId;
|
: customerOrId;
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
return { calendar: false, quotes: false, bills: false };
|
return { calendar: false, quotes: false, bills: false, hoursLogging: false, contracts: false };
|
||||||
}
|
}
|
||||||
const globals = await getCustomerSurfaceGlobals();
|
const globals = await getCustomerSurfaceGlobals();
|
||||||
|
// SQLite returns booleans as 0/1; Postgres returns true/false. The
|
||||||
|
// strict `=== true` check used to falsely return `false` on SQLite,
|
||||||
|
// hiding the sidebar entry even when admin had flipped the per-
|
||||||
|
// customer toggle on. Normalise both shapes here so the Quotes /
|
||||||
|
// Invoices tabs appear consistently.
|
||||||
|
const truthy = (v) => v === true || v === 1 || v === '1' || v === 't';
|
||||||
|
// Hours logging gates on the master feature_flags row (Settings →
|
||||||
|
// Features) AND the per-customer flag. The customer_surface
|
||||||
|
// app_settings layer is admin-side-only here — no portal surface
|
||||||
|
// for hours, so we skip the third gate the bills/quotes use.
|
||||||
|
const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first();
|
||||||
|
const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true;
|
||||||
|
// Contracts (migration 130): no per-customer flag, just the global
|
||||||
|
// feature_flags row. When on, every customer with an active account
|
||||||
|
// sees the Contracts tab on their portal.
|
||||||
|
const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first();
|
||||||
|
const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false;
|
||||||
return {
|
return {
|
||||||
calendar: globals.calendarEnabled && customer.feature_calendar === true,
|
calendar: globals.calendarEnabled && truthy(customer.feature_calendar),
|
||||||
quotes: globals.quotesEnabled && customer.feature_quotes === true,
|
quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
|
||||||
bills: globals.billsEnabled && customer.feature_bills === true,
|
bills: globals.billsEnabled && truthy(customer.feature_bills),
|
||||||
|
hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging),
|
||||||
|
contracts: contractsEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1403,7 @@ async function applyPasswordReset({ token, password }) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createInvitation,
|
createInvitation,
|
||||||
|
createDirect,
|
||||||
acceptInvitation,
|
acceptInvitation,
|
||||||
validateInvitationToken,
|
validateInvitationToken,
|
||||||
listCustomers,
|
listCustomers,
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Customer hour-logging service (migration 129).
|
||||||
|
*
|
||||||
|
* Admin records discrete time blocks against a customer; each entry
|
||||||
|
* eventually folds into an invoice as a single line item. Two flows:
|
||||||
|
*
|
||||||
|
* 1. Monthly-mode customer + feature_hours_logging on
|
||||||
|
* → saving an entry immediately appends a line item onto the
|
||||||
|
* running monthly draft (migration 128 accumulator) and flips
|
||||||
|
* the entry to status='billed'. Admin doesn't have to remember
|
||||||
|
* to convert; the running totals on the customer detail page
|
||||||
|
* reflect the bill that will eventually go out.
|
||||||
|
*
|
||||||
|
* 2. Per-event customer + feature_hours_logging on
|
||||||
|
* → entries sit at status='unbilled' until admin clicks
|
||||||
|
* "Bill these hours" (billUnbilledEntries below). That call
|
||||||
|
* mints a standalone invoice with one line per entry.
|
||||||
|
*
|
||||||
|
* Lockout: once an entry's invoice is "armed for send" (the monthly
|
||||||
|
* scheduler has cleared is_monthly_draft + set scheduled_send_at, or
|
||||||
|
* the invoice transitioned to sent/paid/cancelled), edits + deletes
|
||||||
|
* are refused. Admin must Storno the invoice to change billed hours
|
||||||
|
* — same legal-record discipline as line items today.
|
||||||
|
*/
|
||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const invoiceService = require('./invoiceService');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Pure helpers — exported under `_internal` for direct unit testing.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse two "HH:MM" strings and return the elapsed minutes. Caller
|
||||||
|
* has already validated that start < end; this throws if either is
|
||||||
|
* malformed (defensive — UI should never send a non-conforming value).
|
||||||
|
*/
|
||||||
|
function computeDurationMinutes(start, end) {
|
||||||
|
const re = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
if (!re.test(String(start))) throw new AppError(`Invalid start_time: ${start}`, 400);
|
||||||
|
if (!re.test(String(end))) throw new AppError(`Invalid end_time: ${end}`, 400);
|
||||||
|
const [sh, sm] = String(start).split(':').map((n) => parseInt(n, 10));
|
||||||
|
const [eh, em] = String(end).split(':').map((n) => parseInt(n, 10));
|
||||||
|
const startM = sh * 60 + sm;
|
||||||
|
const endM = eh * 60 + em;
|
||||||
|
if (endM <= startM) throw new AppError('end_time must be after start_time', 400);
|
||||||
|
return endM - startM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the rate this entry should bill at. Override on the entry
|
||||||
|
* wins; otherwise we fall back to the customer's default rate. If
|
||||||
|
* neither is set we throw — saves can't go through without a rate.
|
||||||
|
*/
|
||||||
|
function resolveEffectiveRate(entry, customer) {
|
||||||
|
if (entry.hourly_rate_minor_override != null) {
|
||||||
|
return Number(entry.hourly_rate_minor_override);
|
||||||
|
}
|
||||||
|
if (customer.hourly_rate_minor != null) {
|
||||||
|
return Number(customer.hourly_rate_minor);
|
||||||
|
}
|
||||||
|
throw new AppError(
|
||||||
|
'No hourly rate: set a per-entry override or a customer default.',
|
||||||
|
400,
|
||||||
|
'HOURLY_RATE_REQUIRED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether an entry is still editable. Pure function — callers
|
||||||
|
* pass the loaded entry + (optionally) its current invoice row.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Unbilled entry (no invoice_id) → always editable.
|
||||||
|
* - Linked invoice is still a monthly draft → editable (period open).
|
||||||
|
* - Linked invoice has no scheduled_send_at AND status='scheduled'
|
||||||
|
* → editable (standalone draft).
|
||||||
|
* - Linked invoice has scheduled_send_at > now AND status='scheduled'
|
||||||
|
* → editable until the scheduler arms it.
|
||||||
|
* - Anything else (armed, sent, paid, overdue, cancelled) → locked.
|
||||||
|
*/
|
||||||
|
function isEntryLocked(entry, invoice) {
|
||||||
|
if (!entry.invoice_id) return false;
|
||||||
|
if (!invoice) return false; // entry references a deleted invoice — treat as unbilled
|
||||||
|
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return false;
|
||||||
|
if (invoice.status !== 'scheduled') return true;
|
||||||
|
if (!invoice.scheduled_send_at) return false;
|
||||||
|
return new Date(invoice.scheduled_send_at).getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate an entry row into the line-item shape consumed by
|
||||||
|
* createInvoice / appendToMonthlyDraft. Format:
|
||||||
|
* "{date} {start}–{end} ({hours}h): {note}"
|
||||||
|
* Note suffix omitted when entry.description is null/empty.
|
||||||
|
*/
|
||||||
|
function buildLineItemFromEntry(entry, rateMinor) {
|
||||||
|
const hours = (entry.duration_minutes / 60).toFixed(2);
|
||||||
|
// ISO date input is already YYYY-MM-DD; admin's locale formatting
|
||||||
|
// happens at PDF render time, so keep the entry description portable.
|
||||||
|
const datePart = String(entry.entry_date).slice(0, 10);
|
||||||
|
const note = (entry.description || '').trim();
|
||||||
|
const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
|
||||||
|
const qty = Number(hours);
|
||||||
|
const lineTotalMinor = Math.round(qty * rateMinor);
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
quantity: qty,
|
||||||
|
unit_price_minor: rateMinor,
|
||||||
|
discount_percent: 0,
|
||||||
|
line_total_minor: lineTotalMinor,
|
||||||
|
parent_position: null,
|
||||||
|
details_text: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// CRUD + billing surface
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List entries for a customer. Optional status filter; default sort
|
||||||
|
* is newest entry_date first. Joins to invoices.invoice_number so the
|
||||||
|
* UI can render "Billed on R-2026-0019" without an N+1 round-trip.
|
||||||
|
*/
|
||||||
|
async function listEntries(customerId, { status, limit = 200, offset = 0 } = {}) {
|
||||||
|
let q = db('customer_hour_entries as h')
|
||||||
|
.leftJoin('invoices as i', 'h.invoice_id', 'i.id')
|
||||||
|
.where('h.customer_account_id', customerId);
|
||||||
|
if (status) q = q.where('h.status', status);
|
||||||
|
q = q.orderBy('h.entry_date', 'desc')
|
||||||
|
.orderBy('h.start_time', 'desc')
|
||||||
|
.orderBy('h.id', 'desc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
const rows = await q.select(
|
||||||
|
'h.*',
|
||||||
|
'i.invoice_number as invoice_number',
|
||||||
|
'i.status as invoice_status',
|
||||||
|
'i.is_monthly_draft as invoice_is_monthly_draft',
|
||||||
|
'i.scheduled_send_at as invoice_scheduled_send_at',
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entry. Routes per cadence:
|
||||||
|
* - monthly + feature_hours_logging → append to running draft, flip to billed
|
||||||
|
* - per_event → leave at unbilled, admin bills later
|
||||||
|
*/
|
||||||
|
async function createEntry(customerId, payload, adminId) {
|
||||||
|
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||||
|
if (!customer) throw new AppError('Customer not found', 404);
|
||||||
|
// Both layers must be on: global master switch AND per-customer flag
|
||||||
|
// (matches the quotes/bills AND-logic). Migration 130 added the
|
||||||
|
// global toggle; defaults true on fresh installs.
|
||||||
|
const customerAccountsService = require('./customerAccountsService');
|
||||||
|
const eff = await customerAccountsService.getEffectiveFeaturesForCustomer(customer);
|
||||||
|
if (!eff.hoursLogging) {
|
||||||
|
throw new AppError('Hour logging is not enabled for this customer', 409, 'FEATURE_OFF');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryDate = String(payload.entryDate || '').slice(0, 10);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(entryDate)) {
|
||||||
|
throw new AppError('entryDate must be YYYY-MM-DD', 400);
|
||||||
|
}
|
||||||
|
const startTime = String(payload.startTime || '');
|
||||||
|
const endTime = String(payload.endTime || '');
|
||||||
|
const duration = computeDurationMinutes(startTime, endTime);
|
||||||
|
|
||||||
|
let override = null;
|
||||||
|
if (payload.hourlyRateMinorOverride !== undefined && payload.hourlyRateMinorOverride !== null
|
||||||
|
&& payload.hourlyRateMinorOverride !== '') {
|
||||||
|
const v = parseInt(payload.hourlyRateMinorOverride, 10);
|
||||||
|
if (!Number.isFinite(v) || v < 0) {
|
||||||
|
throw new AppError('hourlyRateMinorOverride must be a non-negative integer', 400);
|
||||||
|
}
|
||||||
|
override = v;
|
||||||
|
}
|
||||||
|
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
|
||||||
|
|
||||||
|
// Pre-validate the rate resolves to something — fail before insert
|
||||||
|
// if neither override nor customer default is set.
|
||||||
|
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer);
|
||||||
|
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
const row = {
|
||||||
|
customer_account_id: customer.id,
|
||||||
|
entry_date: entryDate,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
duration_minutes: duration,
|
||||||
|
hourly_rate_minor_override: override,
|
||||||
|
description,
|
||||||
|
status: 'unbilled',
|
||||||
|
recorded_by_admin_id: adminId,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
};
|
||||||
|
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
|
||||||
|
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
// Monthly-mode customers get the auto-append treatment.
|
||||||
|
if (customer.billing_cadence === 'monthly') {
|
||||||
|
const fullEntry = { ...row, id: entryId };
|
||||||
|
const rate = resolveEffectiveRate(fullEntry, customer);
|
||||||
|
const lineItem = buildLineItemFromEntry(fullEntry, rate);
|
||||||
|
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
|
||||||
|
customer, lineItem, adminId, trx,
|
||||||
|
);
|
||||||
|
await trx('customer_hour_entries').where({ id: entryId }).update({
|
||||||
|
status: 'billed',
|
||||||
|
invoice_id: invoiceId,
|
||||||
|
invoice_line_item_id: lineItemId,
|
||||||
|
billed_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await logActivity('hour_entry_logged_to_monthly_draft',
|
||||||
|
{ entryId, customerId: customer.id, invoiceId },
|
||||||
|
null, `admin:${adminId}`);
|
||||||
|
} catch (_) {}
|
||||||
|
return { id: entryId, status: 'billed', invoiceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity('hour_entry_logged',
|
||||||
|
{ entryId, customerId: customer.id },
|
||||||
|
null, `admin:${adminId}`);
|
||||||
|
} catch (_) {}
|
||||||
|
return { id: entryId, status: 'unbilled' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an entry. Refuses when the entry is locked (linked invoice
|
||||||
|
* has already been armed for send). Otherwise: recomputes duration
|
||||||
|
* from start/end, recomputes the linked line item if billed-but-still-
|
||||||
|
* draft, and recomputes the invoice totals so the running figures
|
||||||
|
* stay accurate.
|
||||||
|
*/
|
||||||
|
async function updateEntry(entryId, payload, adminId) {
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
|
||||||
|
if (!entry) throw new AppError('Entry not found', 404);
|
||||||
|
const invoice = entry.invoice_id
|
||||||
|
? await trx('invoices').where({ id: entry.invoice_id }).first()
|
||||||
|
: null;
|
||||||
|
if (isEntryLocked(entry, invoice)) {
|
||||||
|
throw new AppError(
|
||||||
|
'Entry locked: invoice already armed for send. Storno the invoice to change billed hours.',
|
||||||
|
409,
|
||||||
|
'ENTRY_LOCKED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const customer = await trx('customer_accounts').where({ id: entry.customer_account_id }).first();
|
||||||
|
|
||||||
|
// Merge incoming payload onto the existing row.
|
||||||
|
const next = { ...entry };
|
||||||
|
if (payload.entryDate !== undefined) {
|
||||||
|
const ed = String(payload.entryDate || '').slice(0, 10);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) throw new AppError('entryDate must be YYYY-MM-DD', 400);
|
||||||
|
next.entry_date = ed;
|
||||||
|
}
|
||||||
|
if (payload.startTime !== undefined) next.start_time = String(payload.startTime || '');
|
||||||
|
if (payload.endTime !== undefined) next.end_time = String(payload.endTime || '');
|
||||||
|
if (next.start_time !== entry.start_time || next.end_time !== entry.end_time) {
|
||||||
|
next.duration_minutes = computeDurationMinutes(next.start_time, next.end_time);
|
||||||
|
}
|
||||||
|
if (payload.hourlyRateMinorOverride !== undefined) {
|
||||||
|
if (payload.hourlyRateMinorOverride === null || payload.hourlyRateMinorOverride === '') {
|
||||||
|
next.hourly_rate_minor_override = null;
|
||||||
|
} else {
|
||||||
|
const v = parseInt(payload.hourlyRateMinorOverride, 10);
|
||||||
|
if (!Number.isFinite(v) || v < 0) throw new AppError('hourlyRateMinorOverride must be non-negative', 400);
|
||||||
|
next.hourly_rate_minor_override = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.description !== undefined) {
|
||||||
|
next.description = payload.description ? String(payload.description).slice(0, 1000) : null;
|
||||||
|
}
|
||||||
|
next.updated_at = new Date();
|
||||||
|
|
||||||
|
// Recompute the linked line item if the entry is billed (on a
|
||||||
|
// draft — the lock check above already proved it's mutable).
|
||||||
|
if (entry.invoice_id && entry.invoice_line_item_id) {
|
||||||
|
const rate = resolveEffectiveRate(next, customer);
|
||||||
|
const newLineItem = buildLineItemFromEntry(next, rate);
|
||||||
|
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
|
||||||
|
description: newLineItem.description,
|
||||||
|
quantity: newLineItem.quantity,
|
||||||
|
unit_price_minor: newLineItem.unit_price_minor,
|
||||||
|
line_total_minor: newLineItem.line_total_minor,
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
// Recompute invoice totals — same shape as appendToMonthlyDraft.
|
||||||
|
const allItems = await trx('invoice_line_items').where({ invoice_id: entry.invoice_id });
|
||||||
|
let netMinor = 0;
|
||||||
|
for (const li of allItems) {
|
||||||
|
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
|
||||||
|
}
|
||||||
|
const vatRate = Number(invoice.vat_rate || 0);
|
||||||
|
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||||
|
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
|
||||||
|
const totalMinor = netMinor + vatMinor + shippingMinor;
|
||||||
|
await trx('invoices').where({ id: entry.invoice_id }).update({
|
||||||
|
net_amount_minor: netMinor,
|
||||||
|
vat_amount_minor: vatMinor,
|
||||||
|
total_amount_minor: totalMinor,
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx('customer_hour_entries').where({ id: entryId }).update({
|
||||||
|
entry_date: next.entry_date,
|
||||||
|
start_time: next.start_time,
|
||||||
|
end_time: next.end_time,
|
||||||
|
duration_minutes: next.duration_minutes,
|
||||||
|
hourly_rate_minor_override: next.hourly_rate_minor_override,
|
||||||
|
description: next.description,
|
||||||
|
updated_at: next.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity('hour_entry_updated',
|
||||||
|
{ entryId, customerId: entry.customer_account_id },
|
||||||
|
null, `admin:${adminId}`);
|
||||||
|
} catch (_) {}
|
||||||
|
return { id: entryId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry. Same lockout semantics as update. If the entry is
|
||||||
|
* billed on a still-mutable draft, removes the linked line item and
|
||||||
|
* recomputes invoice totals before deleting the entry row itself.
|
||||||
|
*/
|
||||||
|
async function deleteEntry(entryId, adminId) {
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
|
||||||
|
if (!entry) throw new AppError('Entry not found', 404);
|
||||||
|
const invoice = entry.invoice_id
|
||||||
|
? await trx('invoices').where({ id: entry.invoice_id }).first()
|
||||||
|
: null;
|
||||||
|
if (isEntryLocked(entry, invoice)) {
|
||||||
|
throw new AppError(
|
||||||
|
'Entry locked: invoice already armed for send. Storno the invoice to remove billed hours.',
|
||||||
|
409,
|
||||||
|
'ENTRY_LOCKED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.invoice_line_item_id) {
|
||||||
|
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).del();
|
||||||
|
}
|
||||||
|
if (entry.invoice_id) {
|
||||||
|
const allItems = await trx('invoice_line_items').where({ invoice_id: entry.invoice_id });
|
||||||
|
let netMinor = 0;
|
||||||
|
for (const li of allItems) {
|
||||||
|
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
|
||||||
|
}
|
||||||
|
const vatRate = Number(invoice.vat_rate || 0);
|
||||||
|
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||||
|
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
|
||||||
|
const totalMinor = netMinor + vatMinor + shippingMinor;
|
||||||
|
await trx('invoices').where({ id: entry.invoice_id }).update({
|
||||||
|
net_amount_minor: netMinor,
|
||||||
|
vat_amount_minor: vatMinor,
|
||||||
|
total_amount_minor: totalMinor,
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx('customer_hour_entries').where({ id: entryId }).del();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity('hour_entry_deleted',
|
||||||
|
{ entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id },
|
||||||
|
null, `admin:${adminId}`);
|
||||||
|
} catch (_) {}
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-event flow: mint a standalone invoice from all unbilled entries
|
||||||
|
* for this customer, one line per entry. Refuses when the customer is
|
||||||
|
* monthly-mode (those entries auto-billed on save, so there should be
|
||||||
|
* no unbilled rows). Returns the new invoice id.
|
||||||
|
*/
|
||||||
|
async function billUnbilledEntries(customerId, adminId) {
|
||||||
|
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||||
|
if (!customer) throw new AppError('Customer not found', 404);
|
||||||
|
if (customer.billing_cadence === 'monthly') {
|
||||||
|
throw new AppError(
|
||||||
|
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
|
||||||
|
409,
|
||||||
|
'CADENCE_MISMATCH',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.transaction(async (trx) => {
|
||||||
|
const unbilled = await trx('customer_hour_entries')
|
||||||
|
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||||
|
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||||
|
if (unbilled.length === 0) {
|
||||||
|
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItems = unbilled.map((entry, idx) => {
|
||||||
|
const rate = resolveEffectiveRate(entry, customer);
|
||||||
|
const li = buildLineItemFromEntry(entry, rate);
|
||||||
|
return { ...li, position: idx + 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// No installment metadata — hour-billing always mints a single
|
||||||
|
// standalone invoice. createInvoice returns `{ invoiceIds: [N] }`
|
||||||
|
// since migration 140 / the spawner refactor; extract the one id.
|
||||||
|
const { invoiceIds } = await invoiceService.createInvoice({
|
||||||
|
customerAccountId: customer.id,
|
||||||
|
lineItems,
|
||||||
|
// Reuse the customer/business currency-fallback chain inside
|
||||||
|
// createInvoice.
|
||||||
|
}, adminId, trx);
|
||||||
|
const invoiceId = invoiceIds[0];
|
||||||
|
|
||||||
|
// Locate the newly-inserted line item ids in insertion order so
|
||||||
|
// each entry gets stamped with its specific row.
|
||||||
|
const insertedLines = await trx('invoice_line_items')
|
||||||
|
.where({ invoice_id: invoiceId })
|
||||||
|
.orderBy('position', 'asc');
|
||||||
|
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 0; i < unbilled.length; i += 1) {
|
||||||
|
const entry = unbilled[i];
|
||||||
|
const lineItemId = lineByPos.get(i + 1) || null;
|
||||||
|
await trx('customer_hour_entries').where({ id: entry.id }).update({
|
||||||
|
status: 'billed',
|
||||||
|
invoice_id: invoiceId,
|
||||||
|
invoice_line_item_id: lineItemId,
|
||||||
|
billed_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity('hour_entries_billed',
|
||||||
|
{ customerId: customer.id, invoiceId, entryCount: unbilled.length },
|
||||||
|
null, `admin:${adminId}`);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return { invoiceId, entriesBilled: unbilled.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listEntries,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
billUnbilledEntries,
|
||||||
|
_internal: {
|
||||||
|
computeDurationMinutes,
|
||||||
|
resolveEffectiveRate,
|
||||||
|
isEntryLocked,
|
||||||
|
buildLineItemFromEntry,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* dealsService — read-only lineage queries grouped by `deal_uuid`.
|
||||||
|
*
|
||||||
|
* One UUID spans every quote, contract, and invoice that belongs to
|
||||||
|
* the same customer engagement (migration 140). This module is the
|
||||||
|
* single read surface for "show me everything tied to this deal" so
|
||||||
|
* the frontend's DocumentLineageCard, internal audit traversals, and
|
||||||
|
* any future deal-scoped reports query through one helper instead of
|
||||||
|
* walking the legacy point-to-point FKs each on its own.
|
||||||
|
*
|
||||||
|
* Legacy FK columns (source_quote_id, source_contract_id,
|
||||||
|
* cancels_invoice_id, replaces_invoice_id, cancellation_storno_id,
|
||||||
|
* converted_contract_id, converted_event_id) are still populated on
|
||||||
|
* write so audit logs and PDFs that show "Cancels invoice R-XXXX" or
|
||||||
|
* "From quote Q-XXXX" continue to work — those carry SEMANTIC
|
||||||
|
* relationships (which specific row this one replaces / cancels),
|
||||||
|
* distinct from grouping. The grouping is what this service owns.
|
||||||
|
*
|
||||||
|
* The follow-up cleanup PR (already on the backlog) will drop the
|
||||||
|
* legacy FK columns once deal_uuid is proven stable in production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
function deriveOffsetDays(invoice) {
|
||||||
|
if (!invoice.installment_trigger) return 0;
|
||||||
|
if (invoice.installment_trigger === 'after_delivery') return 0;
|
||||||
|
const sched = invoice.scheduled_send_at
|
||||||
|
? new Date(invoice.scheduled_send_at) : null;
|
||||||
|
if (!sched || Number.isNaN(sched.getTime())) return 0;
|
||||||
|
const anchor = (invoice.installment_trigger === 'before_event'
|
||||||
|
|| invoice.installment_trigger === 'after_event')
|
||||||
|
? invoice.event_date
|
||||||
|
: invoice.issue_date;
|
||||||
|
if (!anchor) return 0;
|
||||||
|
const anchorDate = new Date(anchor);
|
||||||
|
if (Number.isNaN(anchorDate.getTime())) return 0;
|
||||||
|
return Math.round((sched.getTime() - anchorDate.getTime()) / MS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch every document — quotes, contracts, invoices — sharing the
|
||||||
|
* given `deal_uuid`. Each row carries enough state for the lineage
|
||||||
|
* UI to render a clickable entry without a second round-trip:
|
||||||
|
*
|
||||||
|
* - kind: 'quote' | 'contract' | 'invoice'
|
||||||
|
* - id, number (quote_number / contract_number / invoice_number)
|
||||||
|
* - status, currency, total_amount_minor
|
||||||
|
* - issue_date, created_at
|
||||||
|
* - kind-specific extras the renderer needs (e.g. invoice.kind for
|
||||||
|
* Storno detection)
|
||||||
|
*
|
||||||
|
* Returns an object keyed by kind:
|
||||||
|
*
|
||||||
|
* { dealUuid, quotes: [...], contracts: [...], invoices: [...] }
|
||||||
|
*
|
||||||
|
* Sorted within each group by created_at ASC — earliest doc first.
|
||||||
|
* Empty deals (no matches) return all three arrays as []; callers
|
||||||
|
* should treat that as "no related docs", not an error.
|
||||||
|
*/
|
||||||
|
async function getDealDocuments(dealUuid) {
|
||||||
|
if (!dealUuid) {
|
||||||
|
return { dealUuid: null, quotes: [], contracts: [], invoices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [quotes, contracts, invoices] = await Promise.all([
|
||||||
|
db('quotes')
|
||||||
|
.where({ deal_uuid: dealUuid })
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.select(
|
||||||
|
'id', 'quote_number', 'status', 'currency',
|
||||||
|
'total_amount_minor', 'issue_date', 'valid_until',
|
||||||
|
'event_name', 'event_date', 'created_at',
|
||||||
|
),
|
||||||
|
db('contracts')
|
||||||
|
.where({ deal_uuid: dealUuid })
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.select(
|
||||||
|
'id', 'contract_number', 'status', 'title',
|
||||||
|
'issue_date', 'valid_until',
|
||||||
|
'event_name', 'event_date', 'created_at',
|
||||||
|
),
|
||||||
|
db('invoices')
|
||||||
|
.where({ deal_uuid: dealUuid })
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.select(
|
||||||
|
'id', 'invoice_number', 'kind', 'status', 'currency',
|
||||||
|
'total_amount_minor', 'paid_amount_minor',
|
||||||
|
'issue_date', 'due_date',
|
||||||
|
'event_name', 'event_date',
|
||||||
|
// installment_trigger + scheduled_send_at let the lineage card
|
||||||
|
// derive the per-slice trigger/offset_days needed to seed the
|
||||||
|
// Edit Plan modal without a second round-trip.
|
||||||
|
'installment_index', 'installment_total', 'installment_label',
|
||||||
|
'installment_trigger', 'scheduled_send_at',
|
||||||
|
'is_monthly_draft',
|
||||||
|
'created_at',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dealUuid,
|
||||||
|
quotes: quotes.map((q) => ({
|
||||||
|
kind: 'quote',
|
||||||
|
id: q.id,
|
||||||
|
number: q.quote_number,
|
||||||
|
status: q.status,
|
||||||
|
currency: q.currency,
|
||||||
|
totalAmountMinor: q.total_amount_minor,
|
||||||
|
issueDate: q.issue_date,
|
||||||
|
validUntil: q.valid_until,
|
||||||
|
eventName: q.event_name,
|
||||||
|
eventDate: q.event_date,
|
||||||
|
createdAt: q.created_at,
|
||||||
|
})),
|
||||||
|
contracts: contracts.map((c) => ({
|
||||||
|
kind: 'contract',
|
||||||
|
id: c.id,
|
||||||
|
number: c.contract_number,
|
||||||
|
status: c.status,
|
||||||
|
title: c.title,
|
||||||
|
issueDate: c.issue_date,
|
||||||
|
validUntil: c.valid_until,
|
||||||
|
eventName: c.event_name,
|
||||||
|
eventDate: c.event_date,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
})),
|
||||||
|
invoices: invoices.map((i) => ({
|
||||||
|
kind: 'invoice',
|
||||||
|
invoiceKind: i.kind, // 'invoice' | 'storno'
|
||||||
|
id: i.id,
|
||||||
|
number: i.invoice_number,
|
||||||
|
status: i.status,
|
||||||
|
currency: i.currency,
|
||||||
|
totalAmountMinor: i.total_amount_minor,
|
||||||
|
paidAmountMinor: i.paid_amount_minor,
|
||||||
|
issueDate: i.issue_date,
|
||||||
|
dueDate: i.due_date,
|
||||||
|
eventName: i.event_name,
|
||||||
|
eventDate: i.event_date,
|
||||||
|
installmentIndex: i.installment_index,
|
||||||
|
installmentTotal: i.installment_total,
|
||||||
|
installmentLabel: i.installment_label,
|
||||||
|
installmentTrigger: i.installment_trigger || null,
|
||||||
|
// Approximate the original offset_days from the resolved
|
||||||
|
// scheduled_send_at — exact round-trip would need a dedicated
|
||||||
|
// column. The Edit Plan modal uses this as a seed; admin can
|
||||||
|
// override. Anchor by trigger:
|
||||||
|
// - before_event / after_event → days from event_date
|
||||||
|
// - after_delivery → 0 (waits indefinitely)
|
||||||
|
// - quote_accepted / fixed_date → days from issue_date
|
||||||
|
installmentOffsetDays: deriveOffsetDays(i),
|
||||||
|
isMonthlyDraft: Boolean(i.is_monthly_draft),
|
||||||
|
createdAt: i.created_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: resolve a deal_uuid from any document identifier.
|
||||||
|
* Useful for routes that receive an invoice/quote/contract id and
|
||||||
|
* want the full lineage without making the client pass the UUID
|
||||||
|
* explicitly.
|
||||||
|
*
|
||||||
|
* Returns the UUID string, or null if the row doesn't exist.
|
||||||
|
*/
|
||||||
|
async function resolveDealUuidFor(kind, id) {
|
||||||
|
const table = ({ quote: 'quotes', contract: 'contracts', invoice: 'invoices' })[kind];
|
||||||
|
if (!table) return null;
|
||||||
|
const row = await db(table).where({ id }).first('deal_uuid');
|
||||||
|
return row?.deal_uuid || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDealDocuments,
|
||||||
|
resolveDealUuidFor,
|
||||||
|
};
|
||||||
@@ -109,8 +109,29 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
logger.error('Error fetching event language:', error);
|
logger.error('Error fetching event language:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: Check app_settings for general default language
|
// Second priority: customer_accounts.preferred_language matched by
|
||||||
|
// recipient email. Honours the customer's own preference instead of
|
||||||
|
// the app-wide default — fixes the CRM bug where every quote /
|
||||||
|
// invoice / customer email shipped in the app default language
|
||||||
|
// (German on a German-locale install) even when the customer was
|
||||||
|
// explicitly set to English. Falls through silently on miss so admin
|
||||||
|
// recipients (no customer_accounts row) still see the app default.
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
const customer = await db('customer_accounts')
|
||||||
|
.where('email', String(email).toLowerCase().trim())
|
||||||
|
.select('preferred_language')
|
||||||
|
.first();
|
||||||
|
if (customer && customer.preferred_language) {
|
||||||
|
return customer.preferred_language;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Skip customer_accounts language lookup', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third priority: Check app_settings for general default language
|
||||||
try {
|
try {
|
||||||
const langSetting = await db('app_settings')
|
const langSetting = await db('app_settings')
|
||||||
.where('setting_key', 'general_default_language')
|
.where('setting_key', 'general_default_language')
|
||||||
@@ -124,7 +145,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
logger.error('Error fetching app settings language:', error);
|
logger.error('Error fetching app settings language:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third priority: Check email configs for default language
|
// Fourth priority: Check email configs for default language
|
||||||
try {
|
try {
|
||||||
const emailConfig = await db('email_configs').first();
|
const emailConfig = await db('email_configs').first();
|
||||||
if (emailConfig && emailConfig.default_language) {
|
if (emailConfig && emailConfig.default_language) {
|
||||||
@@ -133,8 +154,8 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching email config language:', error);
|
logger.error('Error fetching email config language:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fourth priority: Check if the email domain suggests a language
|
// Fifth priority: Check if the email domain suggests a language
|
||||||
if (email) {
|
if (email) {
|
||||||
const domain = email.toLowerCase();
|
const domain = email.toLowerCase();
|
||||||
const domainLanguageMap = [
|
const domainLanguageMap = [
|
||||||
@@ -675,13 +696,34 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
|||||||
// Process template with variables
|
// Process template with variables
|
||||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||||
|
|
||||||
|
// Optional plumbing — quote/invoice emails set these. Attachments
|
||||||
|
// are passed by callers as [{ filename, contentPath }] where the
|
||||||
|
// file is already written to disk; nodemailer streams it.
|
||||||
|
const ccList = Array.isArray(variables.cc)
|
||||||
|
? variables.cc.filter(Boolean)
|
||||||
|
: (typeof variables.cc === 'string' && variables.cc.trim())
|
||||||
|
? variables.cc.split(/[,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const attachments = Array.isArray(variables.attachments)
|
||||||
|
? variables.attachments
|
||||||
|
.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||||
|
.map((a) => ({
|
||||||
|
filename: a.filename,
|
||||||
|
path: a.contentPath || a.path,
|
||||||
|
content: a.content,
|
||||||
|
contentType: a.contentType,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `${config.from_name} <${config.from_email}>`,
|
from: `${config.from_name} <${config.from_email}>`,
|
||||||
to: to,
|
to: to,
|
||||||
|
cc: ccList,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: htmlBody,
|
html: htmlBody,
|
||||||
text: textBody || htmlToText(htmlBody)
|
text: textBody || htmlToText(htmlBody),
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
|
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
|
||||||
@@ -709,9 +751,17 @@ async function processEmailQueue() {
|
|||||||
|
|
||||||
let pendingEmails = [];
|
let pendingEmails = [];
|
||||||
try {
|
try {
|
||||||
|
// Pick up emails that are pending AND either have no `scheduled_at`
|
||||||
|
// or whose scheduled_at is in the past. Used by CRM invoices to
|
||||||
|
// queue split-payment emails relative to the event date.
|
||||||
|
const now = new Date();
|
||||||
pendingEmails = await db('email_queue')
|
pendingEmails = await db('email_queue')
|
||||||
.where('status', 'pending')
|
.where('status', 'pending')
|
||||||
.where('retry_count', '<', 3)
|
.where('retry_count', '<', 3)
|
||||||
|
.andWhere(function() {
|
||||||
|
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
|
||||||
|
})
|
||||||
|
.orderBy('scheduled_at', 'asc')
|
||||||
.orderBy('created_at', 'asc')
|
.orderBy('created_at', 'asc')
|
||||||
.limit(10);
|
.limit(10);
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
@@ -776,22 +826,35 @@ async function processEmailQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue an email for sending
|
// Queue an email for sending. Optionally takes a 5th `options` arg:
|
||||||
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
// options.scheduledAt — Date | ISO string; row only picks up once
|
||||||
|
// this moment has passed (used by CRM split-
|
||||||
|
// payment invoices). NULL = send immediately.
|
||||||
|
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
|
||||||
|
// so callers don't need a new signature for every email shape.
|
||||||
|
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
|
||||||
try {
|
try {
|
||||||
// Add eventId to emailData for language detection
|
// Add eventId to emailData for language detection
|
||||||
emailData.eventId = eventId;
|
emailData.eventId = eventId;
|
||||||
await db('email_queue').insert({
|
const row = {
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
recipient_email: recipientEmail,
|
recipient_email: recipientEmail,
|
||||||
email_type: emailType,
|
email_type: emailType,
|
||||||
email_data: JSON.stringify(emailData),
|
email_data: JSON.stringify(emailData),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
created_at: new Date()
|
created_at: new Date(),
|
||||||
});
|
};
|
||||||
|
if (options.scheduledAt) {
|
||||||
logger.info(`Email queued: ${emailType} to ${recipientEmail}`);
|
row.scheduled_at = options.scheduledAt instanceof Date
|
||||||
|
? options.scheduledAt
|
||||||
|
: new Date(options.scheduledAt);
|
||||||
|
}
|
||||||
|
await db('email_queue').insert(row);
|
||||||
|
|
||||||
|
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
||||||
|
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
|
||||||
|
}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error queueing email:', error);
|
logger.error('Error queueing email:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* eventReminderService — pre-event customer reminder emails
|
||||||
|
* (migration 143).
|
||||||
|
*
|
||||||
|
* Sends ONE reminder per event N days before `event_date`. Goal: nudge
|
||||||
|
* the customer on prep — space for equipment setup, dress-code notes,
|
||||||
|
* access logistics — so the photographer arrives to a workable scene.
|
||||||
|
*
|
||||||
|
* **Wiring**
|
||||||
|
*
|
||||||
|
* `runEventReminderPass()` is invoked from the invoice scheduler's
|
||||||
|
* hourly cron tick (commit #3 of this feature). Idempotent: every send
|
||||||
|
* stamps `events.event_reminder_sent_at`; subsequent ticks skip rows
|
||||||
|
* with a non-null timestamp.
|
||||||
|
*
|
||||||
|
* **Template resolution**
|
||||||
|
*
|
||||||
|
* 1. `event_reminder_<events.event_type>` — per-type template, if
|
||||||
|
* seeded. Admin manages these via the existing email-template
|
||||||
|
* editor (no schema rule restricts what they can create here;
|
||||||
|
* whatever slug-prefixed templates exist will match).
|
||||||
|
* 2. `event_reminder_default` — catch-all, seeded by migration 143.
|
||||||
|
*
|
||||||
|
* Falls through silently when the catch-all is missing (logs a warn
|
||||||
|
* but doesn't throw — the cron must not crash the whole tick because
|
||||||
|
* of one stale install).
|
||||||
|
*
|
||||||
|
* **Override precedence per event**
|
||||||
|
*
|
||||||
|
* - `events.event_reminder_disabled = true` → skip
|
||||||
|
* - `events.event_reminder_offset_days` (nullable int) → overrides
|
||||||
|
* the global `crm_event_reminders_days_before`
|
||||||
|
* - `events.event_reminder_body_override` (text) → if set,
|
||||||
|
* replaces the template body verbatim. Subject still comes from
|
||||||
|
* the template. Useful for one-off "the venue has no loading zone,
|
||||||
|
* arrive via the rear door"-style notes.
|
||||||
|
*
|
||||||
|
* **Recipient**
|
||||||
|
*
|
||||||
|
* Only the event's primary customer (`events.customer_account_id`).
|
||||||
|
* Multi-customer assignments via `event_customer_assignments` are NOT
|
||||||
|
* notified — confirmed with maintainer 2026-05-25. Events without a
|
||||||
|
* customer_account_id or without an email on file are skipped.
|
||||||
|
*
|
||||||
|
* **Snapshot semantics**
|
||||||
|
*
|
||||||
|
* We resolve + send eagerly per tick. The current shape stamps the
|
||||||
|
* sent_at timestamp on send — we deliberately do NOT snapshot the
|
||||||
|
* resolved body onto the event row at scheduling time, because the
|
||||||
|
* candidate window is short (N days before event) and the cron picks
|
||||||
|
* the freshest template every pass until the moment of send. If a
|
||||||
|
* future "schedule N hours ahead, freeze the body, send later" model
|
||||||
|
* is needed, add a snapshot column and resolve at scheduling time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const emailProcessor = require('./emailProcessor');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates');
|
||||||
|
|
||||||
|
const DEFAULT_DAYS_BEFORE = 2;
|
||||||
|
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
|
||||||
|
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
|
||||||
|
|
||||||
|
// One-shot guard: the "schema not migrated" warn would otherwise fire
|
||||||
|
// once per cron tick (≈ hourly) on installs that haven't applied
|
||||||
|
// migration 143 yet. Log on the first encounter only — subsequent
|
||||||
|
// ticks no-op silently.
|
||||||
|
let schemaWarnLogged = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup the most specific available template for an event_type slug.
|
||||||
|
* Returns the template_key string. The email_processor handles missing
|
||||||
|
* template rows by failing the send; we don't fetch the row body here
|
||||||
|
* because emailProcessor.queueEmail does that lookup itself.
|
||||||
|
*/
|
||||||
|
async function resolveTemplateKey(eventType) {
|
||||||
|
if (eventType) {
|
||||||
|
const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`;
|
||||||
|
const exists = await db('email_templates')
|
||||||
|
.where({ template_key: perType })
|
||||||
|
.first('id');
|
||||||
|
if (exists) return perType;
|
||||||
|
}
|
||||||
|
return TEMPLATE_KEY_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the variables payload the template engine substitutes. Keep
|
||||||
|
* the keys in sync with the seeded template's `variables` JSON.
|
||||||
|
*/
|
||||||
|
function composePayload({ event, customer, daysBefore, businessName }) {
|
||||||
|
const customerName = customer.company_name
|
||||||
|
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|
||||||
|
|| customer.display_name
|
||||||
|
|| customer.email
|
||||||
|
|| '';
|
||||||
|
// Event date formatted DD.MM.YYYY here for simplicity; the rendered
|
||||||
|
// email may further re-locale via the template engine when locale-
|
||||||
|
// aware formatters are introduced.
|
||||||
|
const ed = event.event_date instanceof Date ? event.event_date : new Date(event.event_date);
|
||||||
|
const day = String(ed.getUTCDate()).padStart(2, '0');
|
||||||
|
const month = String(ed.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const year = ed.getUTCFullYear();
|
||||||
|
const eventDateFormatted = `${day}.${month}.${year}`;
|
||||||
|
return {
|
||||||
|
customer_name: customerName,
|
||||||
|
event_name: event.event_name || `Event #${event.id}`,
|
||||||
|
event_date: eventDateFormatted,
|
||||||
|
event_type: event.event_type || '',
|
||||||
|
days_before: daysBefore,
|
||||||
|
business_name: businessName || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass of the reminder loop. Idempotent. Errors on individual
|
||||||
|
* events are caught and logged so a single bad row doesn't kill the
|
||||||
|
* whole tick.
|
||||||
|
*
|
||||||
|
* Returns `{ scanned, sent, skipped }` counters for logging.
|
||||||
|
*/
|
||||||
|
async function runEventReminderPass() {
|
||||||
|
const enabled = await getAppSetting('crm_event_reminders_enabled');
|
||||||
|
if (enabled !== true && enabled !== 'true' && enabled !== 1 && enabled !== '1') {
|
||||||
|
return { scanned: 0, sent: 0, skipped: 0, disabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column-existence guards — pre-migration installs return early
|
||||||
|
// instead of throwing.
|
||||||
|
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
|
||||||
|
if (!hasCols) {
|
||||||
|
if (!schemaWarnLogged) {
|
||||||
|
logger.warn('Event reminder pass skipped — schema not yet migrated (run migration 143). Suppressing further warnings until restart.');
|
||||||
|
schemaWarnLogged = true;
|
||||||
|
}
|
||||||
|
return { scanned: 0, sent: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal the seeded templates. Idempotent — only inserts missing
|
||||||
|
// rows and backfills empty translations, never overwrites edits.
|
||||||
|
// Runs once per process (module-level cache); subsequent ticks no-op.
|
||||||
|
try {
|
||||||
|
await ensureEventReminderTemplatesSeeded(db, logger);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Event reminder template self-heal failed', { message: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
|
||||||
|
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
|
||||||
|
? globalDaysBefore : DEFAULT_DAYS_BEFORE;
|
||||||
|
|
||||||
|
// Pull the business name once per pass for the payload.
|
||||||
|
const profile = await db('business_profile').where({ id: 1 }).first('company_name');
|
||||||
|
const businessName = profile?.company_name || '';
|
||||||
|
|
||||||
|
// Candidate set: events with a customer, event_date in the future,
|
||||||
|
// not yet sent, not disabled per-event. We don't filter on
|
||||||
|
// event_date - days_before <= NOW() in SQL because per-event
|
||||||
|
// override `event_reminder_offset_days` may shift the trigger
|
||||||
|
// window — easier to filter in JS.
|
||||||
|
const now = new Date();
|
||||||
|
const rows = await db('events')
|
||||||
|
.leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id')
|
||||||
|
.whereNotNull('events.customer_account_id')
|
||||||
|
.whereNotNull('events.event_date')
|
||||||
|
.where('events.is_active', true)
|
||||||
|
.where('events.is_archived', false)
|
||||||
|
.where('events.event_reminder_disabled', false)
|
||||||
|
.whereNull('events.event_reminder_sent_at')
|
||||||
|
.where('events.event_date', '>=', now.toISOString().slice(0, 10))
|
||||||
|
.select(
|
||||||
|
'events.id', 'events.event_name', 'events.event_type', 'events.event_date',
|
||||||
|
'events.event_reminder_offset_days',
|
||||||
|
'events.event_reminder_body_override',
|
||||||
|
'events.customer_account_id',
|
||||||
|
'customer_accounts.email as customer_email',
|
||||||
|
'customer_accounts.first_name as customer_first_name',
|
||||||
|
'customer_accounts.last_name as customer_last_name',
|
||||||
|
'customer_accounts.display_name as customer_display_name',
|
||||||
|
'customer_accounts.company_name as customer_company_name',
|
||||||
|
);
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
if (!row.customer_email) { skipped += 1; continue; }
|
||||||
|
const offsetDays = Number.isFinite(Number(row.event_reminder_offset_days))
|
||||||
|
? Number(row.event_reminder_offset_days)
|
||||||
|
: daysBeforeDefault;
|
||||||
|
// Trigger window: NOW >= event_date - offset_days.
|
||||||
|
const ed = row.event_date instanceof Date ? row.event_date : new Date(row.event_date);
|
||||||
|
const triggerAt = new Date(ed.getTime() - offsetDays * 86_400_000);
|
||||||
|
if (now < triggerAt) { skipped += 1; continue; }
|
||||||
|
|
||||||
|
const templateKey = await resolveTemplateKey(row.event_type);
|
||||||
|
const customer = {
|
||||||
|
email: row.customer_email,
|
||||||
|
first_name: row.customer_first_name,
|
||||||
|
last_name: row.customer_last_name,
|
||||||
|
display_name: row.customer_display_name,
|
||||||
|
company_name: row.customer_company_name,
|
||||||
|
};
|
||||||
|
const payload = composePayload({
|
||||||
|
event: row, customer, daysBefore: offsetDays, businessName,
|
||||||
|
});
|
||||||
|
// Per-event body override: when present, append as a synthetic
|
||||||
|
// `body_override` field. The template engine should branch on it
|
||||||
|
// (e.g. Handlebars `{{#if body_override}}{{body_override}}{{else}}…default body…{{/if}}`).
|
||||||
|
// For installs where the templates don't yet handle the branch,
|
||||||
|
// the override still rides through as a variable the admin can
|
||||||
|
// reference manually.
|
||||||
|
if (row.event_reminder_body_override) {
|
||||||
|
payload.body_override = row.event_reminder_body_override;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload);
|
||||||
|
|
||||||
|
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
|
||||||
|
// crash between queueEmail and the update) doesn't double-send
|
||||||
|
// on the next tick. The queueEmail call is itself idempotent at
|
||||||
|
// the queue level; we belt-and-suspenders here.
|
||||||
|
await db('events')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({ event_reminder_sent_at: new Date() });
|
||||||
|
sent += 1;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Event reminder send failed', {
|
||||||
|
eventId: row.id, err: err.message,
|
||||||
|
});
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production-quiet: only log when something actually happened
|
||||||
|
// (a send or a skipped row inside the trigger window). Empty passes
|
||||||
|
// — common when there are no upcoming events — stay silent so the
|
||||||
|
// hourly cron doesn't paper the logs.
|
||||||
|
if (sent > 0) {
|
||||||
|
logger.info('Event reminder pass: sent reminders', {
|
||||||
|
scanned: rows.length, sent, skipped,
|
||||||
|
});
|
||||||
|
} else if (skipped > 0) {
|
||||||
|
// skipped > 0 with sent === 0 means at least one event WAS in the
|
||||||
|
// window but couldn't be sent (missing email, send error). Log at
|
||||||
|
// info so it's visible without being noisy on healthy passes.
|
||||||
|
logger.info('Event reminder pass: rows skipped (no-send)', {
|
||||||
|
scanned: rows.length, skipped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { scanned: rows.length, sent, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
runEventReminderPass,
|
||||||
|
// exported for tests
|
||||||
|
_internal: {
|
||||||
|
resolveTemplateKey,
|
||||||
|
composePayload,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,10 +11,49 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
const eventTypeService = require('./eventTypeService');
|
const eventTypeService = require('./eventTypeService');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce + validate the (event_time_start, event_time_end, is_full_day)
|
||||||
|
* triple from a payload. Migration 137 introduced these columns on the
|
||||||
|
* events table. Contract:
|
||||||
|
* - is_full_day defaults to true when undefined (preserves legacy
|
||||||
|
* callers that don't know about the new fields).
|
||||||
|
* - is_full_day=true forces both times to null regardless of what
|
||||||
|
* was supplied (full-day events never carry HH:MM).
|
||||||
|
* - is_full_day=false requires both times in HH:MM 24h form, and
|
||||||
|
* `end > start` lexicographically (string compare is safe for the
|
||||||
|
* 5-char HH:MM format).
|
||||||
|
* Throws AppError 400 on failure. Returns a normalised
|
||||||
|
* { event_time_start, event_time_end, is_full_day } triple suitable
|
||||||
|
* for direct DB write (boolean still coerced via formatBoolean at the
|
||||||
|
* write site).
|
||||||
|
*/
|
||||||
|
function normaliseEventTimeTriple({ event_time_start, event_time_end, is_full_day }) {
|
||||||
|
const isFullDay = is_full_day === undefined ? true : parseBooleanInput(is_full_day, true);
|
||||||
|
if (isFullDay) {
|
||||||
|
return { event_time_start: null, event_time_end: null, is_full_day: true };
|
||||||
|
}
|
||||||
|
const start = parseStringInput(event_time_start);
|
||||||
|
const end = parseStringInput(event_time_end);
|
||||||
|
if (!start || !TIME_RE.test(start)) {
|
||||||
|
throw new AppError('event_time_start must be HH:MM (24h)', 400, 'EVENT_TIME_INVALID');
|
||||||
|
}
|
||||||
|
if (!end || !TIME_RE.test(end)) {
|
||||||
|
throw new AppError('event_time_end must be HH:MM (24h)', 400, 'EVENT_TIME_INVALID');
|
||||||
|
}
|
||||||
|
if (start >= end) {
|
||||||
|
throw new AppError('event_time_end must be after event_time_start', 400, 'EVENT_TIME_RANGE');
|
||||||
|
}
|
||||||
|
return { event_time_start: start, event_time_end: end, is_full_day: false };
|
||||||
|
}
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -140,11 +179,21 @@ const createEvent = async (eventData) => {
|
|||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
upload_category_id,
|
upload_category_id,
|
||||||
// Photo cap
|
// Photo cap
|
||||||
photo_cap
|
photo_cap,
|
||||||
|
// Migration 137 — calendar time fields. Defaults to full-day when
|
||||||
|
// the caller (legacy create-event form) doesn't know about them.
|
||||||
|
event_time_start,
|
||||||
|
event_time_end,
|
||||||
|
is_full_day
|
||||||
} = eventData;
|
} = eventData;
|
||||||
|
|
||||||
const requirePassword = parseBooleanInput(require_password, true);
|
const requirePassword = parseBooleanInput(require_password, true);
|
||||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||||
|
// Validate + normalise the calendar time triple up front so we throw
|
||||||
|
// before bcrypt + folder creation if the payload is bad.
|
||||||
|
const timeTriple = normaliseEventTimeTriple({
|
||||||
|
event_time_start, event_time_end, is_full_day,
|
||||||
|
});
|
||||||
|
|
||||||
// Validate password if required
|
// Validate password if required
|
||||||
if (requirePassword) {
|
if (requirePassword) {
|
||||||
@@ -214,6 +263,15 @@ const createEvent = async (eventData) => {
|
|||||||
photo_cap: photo_cap || null
|
photo_cap: photo_cap || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Migration 137 — calendar time fields. Guarded by hasColumnCached so
|
||||||
|
// installs that haven't applied 137 yet skip the columns silently
|
||||||
|
// (per feedback_schema_drift_guards.md / feedback_cache_hasColumn_lookups.md).
|
||||||
|
if (await hasColumnCached('events', 'is_full_day')) {
|
||||||
|
insertData.event_time_start = timeTriple.event_time_start;
|
||||||
|
insertData.event_time_end = timeTriple.event_time_end;
|
||||||
|
insertData.is_full_day = formatBoolean(timeTriple.is_full_day);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove undefined values
|
// Remove undefined values
|
||||||
Object.keys(insertData).forEach(key => {
|
Object.keys(insertData).forEach(key => {
|
||||||
if (insertData[key] === undefined) {
|
if (insertData[key] === undefined) {
|
||||||
@@ -360,6 +418,33 @@ const updateEvent = async (id, updates) => {
|
|||||||
delete updates.password;
|
delete updates.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 137 — calendar time fields. We re-normalise the triple
|
||||||
|
// ONLY when at least one of the three fields was supplied; otherwise
|
||||||
|
// leave the row's current values alone. is_full_day=true forces both
|
||||||
|
// times to null regardless of what was supplied.
|
||||||
|
const timeFieldsTouched = (
|
||||||
|
updates.event_time_start !== undefined
|
||||||
|
|| updates.event_time_end !== undefined
|
||||||
|
|| updates.is_full_day !== undefined
|
||||||
|
);
|
||||||
|
if (timeFieldsTouched) {
|
||||||
|
if (await hasColumnCached('events', 'is_full_day')) {
|
||||||
|
const triple = normaliseEventTimeTriple({
|
||||||
|
event_time_start: updates.event_time_start,
|
||||||
|
event_time_end: updates.event_time_end,
|
||||||
|
is_full_day: updates.is_full_day,
|
||||||
|
});
|
||||||
|
updates.event_time_start = triple.event_time_start;
|
||||||
|
updates.event_time_end = triple.event_time_end;
|
||||||
|
updates.is_full_day = formatBoolean(triple.is_full_day);
|
||||||
|
} else {
|
||||||
|
// Un-migrated install — drop the fields silently.
|
||||||
|
delete updates.event_time_start;
|
||||||
|
delete updates.event_time_end;
|
||||||
|
delete updates.is_full_day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db('events').where('id', id).update(updates);
|
await db('events').where('id', id).update(updates);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -412,5 +497,9 @@ module.exports = {
|
|||||||
mapEventForApi,
|
mapEventForApi,
|
||||||
hasCustomerContactColumns,
|
hasCustomerContactColumns,
|
||||||
generateUniqueSlug,
|
generateUniqueSlug,
|
||||||
createEventFolders
|
createEventFolders,
|
||||||
|
// Calendar time triple normaliser (migration 137). Exported so the
|
||||||
|
// inline adminEvents POST/PUT (which doesn't go through createEvent)
|
||||||
|
// can share the validation contract.
|
||||||
|
normaliseEventTimeTriple
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,12 +118,17 @@ function frontendBase() {
|
|||||||
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventDate(value) {
|
// Render the event date for the OG preview card respecting the
|
||||||
|
// admin-configured `general_date_format` (defaults to DD.MM.YYYY when
|
||||||
|
// unset). Previously hardcoded en-US "May 20, 2026" which ignored the
|
||||||
|
// operator's locale.
|
||||||
|
async function formatEventDate(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
try {
|
try {
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
const { formatDate } = require('../utils/dateFormatter');
|
||||||
|
return await formatDate(d);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -147,7 +152,7 @@ async function buildOgMetadata(slug, requestPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventName = event.event_name || 'Photo Gallery';
|
const eventName = event.event_name || 'Photo Gallery';
|
||||||
const eventDate = formatEventDate(event.event_date);
|
const eventDate = await formatEventDate(event.event_date);
|
||||||
const titleParts = [eventName];
|
const titleParts = [eventName];
|
||||||
if (siteName && siteName !== eventName) titleParts.push(siteName);
|
if (siteName && siteName !== eventName) titleParts.push(siteName);
|
||||||
const title = titleParts.join(' — ');
|
const title = titleParts.join(' — ');
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* invoiceSchedulerService — cron worker for CRM automation.
|
||||||
|
*
|
||||||
|
* Despite the name, this scheduler now drives THREE jobs:
|
||||||
|
* 1. Flush invoices whose `scheduled_send_at` has passed and status
|
||||||
|
* is still 'scheduled' — flips them to 'sent' and queues the email.
|
||||||
|
* 2. Run the overdue reminder ladder (first reminder at due_date +
|
||||||
|
* reminder_first_days, second at +second_days w/ late fee).
|
||||||
|
* 3. Pre-event customer reminders (migration 143) — sends a nudge
|
||||||
|
* N days before `event_date`. Idempotent via
|
||||||
|
* `events.event_reminder_sent_at`.
|
||||||
|
*
|
||||||
|
* Jobs 1+2 delegate to `invoiceService.runScheduledTasks()`; job 3
|
||||||
|
* to `eventReminderService.runEventReminderPass()`. The two service
|
||||||
|
* calls run sequentially inside the same tick but in independent
|
||||||
|
* try/catch blocks so a failure in one doesn't suppress the other.
|
||||||
|
*
|
||||||
|
* Wired in server.js boot path next to expirationChecker — see that
|
||||||
|
* module for the cron pattern. Runs hourly; the per-row guards inside
|
||||||
|
* each service prevent duplicate sends.
|
||||||
|
*
|
||||||
|
* The module name is kept as `invoiceSchedulerService` for backward
|
||||||
|
* compatibility with the existing server.js import; rename to
|
||||||
|
* `crmSchedulerService` is a future cleanup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const invoiceService = require('./invoiceService');
|
||||||
|
const eventReminderService = require('./eventReminderService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
let task = null;
|
||||||
|
|
||||||
|
async function runTick() {
|
||||||
|
try {
|
||||||
|
await invoiceService.runScheduledTasks();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Invoice scheduler tick failed', { err: err.message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await eventReminderService.runEventReminderPass();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Event reminder pass failed', { err: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInvoiceScheduler() {
|
||||||
|
if (task) {
|
||||||
|
logger.info('Invoice scheduler already running');
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
// Hourly at minute 11 to spread load away from other hourly jobs.
|
||||||
|
task = cron.schedule('11 * * * *', async () => {
|
||||||
|
logger.info('Invoice scheduler: tick');
|
||||||
|
await runTick();
|
||||||
|
});
|
||||||
|
logger.info('Invoice scheduler started (hourly @ :11) — invoice + event-reminder jobs');
|
||||||
|
// Run once on boot so a missed window (server restart) gets caught
|
||||||
|
// up immediately.
|
||||||
|
runTick().catch((err) => {
|
||||||
|
logger.warn('Invoice scheduler initial tick failed', { err: err.message });
|
||||||
|
});
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopInvoiceScheduler() {
|
||||||
|
if (task) {
|
||||||
|
task.stop();
|
||||||
|
task = null;
|
||||||
|
logger.info('Invoice scheduler stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* Backend-only label map for quote / invoice PDF rendering.
|
||||||
|
*
|
||||||
|
* PDFs are generated outside React, so we can't reuse `react-i18next`.
|
||||||
|
* This is a self-contained, additive label dictionary keyed by locale.
|
||||||
|
*
|
||||||
|
* Per project convention: en + de are hand-translated; fr / nl / pt / ru
|
||||||
|
* are machine-translated and flagged in the PR description for native
|
||||||
|
* review (see MEMORY.md → feedback_translation_flagging.md).
|
||||||
|
*
|
||||||
|
* Anything missing for a locale falls through to English at call site
|
||||||
|
* via `t(labels, locale, key)`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
en: {
|
||||||
|
quote_title: 'Quote',
|
||||||
|
invoice_title: 'Invoice',
|
||||||
|
quote_number_label: 'Quote number',
|
||||||
|
invoice_number_label: 'Invoice number',
|
||||||
|
// Stornorechnung (cancellation invoice). Distinct from
|
||||||
|
// `invoice_title` so the renderer can swap the page title when
|
||||||
|
// `doc.kind === 'storno'`. `reference_cancels` powers the
|
||||||
|
// mandatory "Bezug: Storno zu Rechnung R-XXXX vom DATE" line
|
||||||
|
// under the title that the customer/auditor needs to trace the
|
||||||
|
// §14c-defensible reversal.
|
||||||
|
storno_title: 'Cancellation invoice',
|
||||||
|
reference_cancels: 'Cancels',
|
||||||
|
date: 'Date',
|
||||||
|
quote_number: 'Quote',
|
||||||
|
invoice_number: 'Invoice',
|
||||||
|
valid_until: 'Valid until',
|
||||||
|
due_date: 'Due',
|
||||||
|
salutation: 'Dear Sir or Madam,',
|
||||||
|
lead_in_quote: 'in accordance with our agreement, we are pleased to offer the following:',
|
||||||
|
lead_in_invoice: 'in accordance with our agreement, we are invoicing the following:',
|
||||||
|
table_pos: 'Pos.',
|
||||||
|
table_qty: 'Qty',
|
||||||
|
table_description: 'Description',
|
||||||
|
table_discount: 'Discount',
|
||||||
|
table_unit_price: 'Unit price',
|
||||||
|
table_line_total: 'Total',
|
||||||
|
totals_net: 'Net amount',
|
||||||
|
totals_shipping: 'Shipping',
|
||||||
|
totals_vat: 'VAT',
|
||||||
|
totals_late_fee: 'Late fee',
|
||||||
|
totals_grand: 'Total',
|
||||||
|
payment_conditions: 'Payment conditions',
|
||||||
|
iban_intro: 'Please transfer the amount to the following bank account:',
|
||||||
|
net_days_suffix: 'days from invoice date.',
|
||||||
|
skonto_label: 'Early payment discount',
|
||||||
|
skonto_phrase: '{percent}% discount if paid within {days} working days.',
|
||||||
|
skonto_amount_label: 'Amount with discount',
|
||||||
|
late_fee_note: 'A late fee of {amount} has been added due to overdue payment.',
|
||||||
|
installment_due: 'due',
|
||||||
|
reference_label: 'Reference',
|
||||||
|
reference_replaces: 'Replaces',
|
||||||
|
reference_dated: 'dated {date}',
|
||||||
|
page: 'Page',
|
||||||
|
of: 'of',
|
||||||
|
page_of: 'Page {current} of {total}',
|
||||||
|
epc_qr_title: 'Scan to pay (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Open your banking app and scan this code to prefill the transfer.',
|
||||||
|
quote_response_intro: 'You can accept or decline this quote here:',
|
||||||
|
accept_button: 'Accept quote',
|
||||||
|
decline_button: 'Decline quote',
|
||||||
|
// Tax report (commit 3/feat-crm). Hand-translated en + de here;
|
||||||
|
// fr/nl/pt/ru are filled in by commit 5 and fall through to en
|
||||||
|
// until then.
|
||||||
|
tax_title: 'Tax report',
|
||||||
|
tax_period: 'Period',
|
||||||
|
tax_generated: 'Generated',
|
||||||
|
tax_currency: 'Currency',
|
||||||
|
tax_col_no: '#',
|
||||||
|
tax_col_date: 'Date',
|
||||||
|
tax_col_invoice: 'Invoice',
|
||||||
|
tax_col_customer: 'Customer',
|
||||||
|
tax_col_event: 'Event',
|
||||||
|
tax_col_vat_rate: 'VAT %',
|
||||||
|
tax_col_net: 'Net',
|
||||||
|
tax_col_vat: 'VAT',
|
||||||
|
tax_col_total: 'Gross',
|
||||||
|
tax_col_status: 'Status',
|
||||||
|
tax_col_skonto: 'Skonto',
|
||||||
|
tax_status_cancelled: 'Cancelled',
|
||||||
|
tax_totals_by_rate: 'Totals by VAT rate',
|
||||||
|
tax_grand_total_net: 'Total net',
|
||||||
|
tax_grand_total_vat: 'Total VAT',
|
||||||
|
tax_grand_total_gross: 'Total gross',
|
||||||
|
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).',
|
||||||
|
tax_no_invoices: 'No invoices in this period.',
|
||||||
|
// Contracts (migration 130). Section labels stay in sync with the
|
||||||
|
// SECTIONS_ORDER enum in contractService.
|
||||||
|
contract_title: 'Contract',
|
||||||
|
contract_number_label: 'Contract no.',
|
||||||
|
section_basics: 'Basics',
|
||||||
|
section_scope: 'Scope',
|
||||||
|
section_privacy: 'Privacy',
|
||||||
|
section_commercial: 'Commercial',
|
||||||
|
section_nda: 'Confidentiality',
|
||||||
|
section_closing: 'Closing provisions',
|
||||||
|
signature_customer: 'Client',
|
||||||
|
signature_admin: 'Contractor',
|
||||||
|
signed_label_name: 'Name',
|
||||||
|
signed_label_date: 'Date',
|
||||||
|
signed_label_place: 'Place',
|
||||||
|
signed_label_signature: 'Signature',
|
||||||
|
signed_at: 'Signed at',
|
||||||
|
// Dedicated signature page at the end of every contract PDF.
|
||||||
|
// Stamp service overlays canvas signatures onto the empty boxes
|
||||||
|
// at fixed coordinates; admin / customer labels stay in-place.
|
||||||
|
signature_page_title: 'Signatures',
|
||||||
|
signature_page_prompt: 'Both parties confirm acceptance of the terms above by signing below.',
|
||||||
|
// Audit certificate — separate PDF (no longer in the contract
|
||||||
|
// body) listing timestamps, IPs, and SHA-256 hashes. Generated
|
||||||
|
// by pdfStampService.renderAuditCertificate.
|
||||||
|
audit_certificate_subject: 'Signing audit certificate',
|
||||||
|
audit_title: 'Signing audit trail',
|
||||||
|
audit_intro: 'The evidence below was recorded automatically when this contract was signed. To verify file integrity, re-hash the PDF you hold with any SHA-256 utility and compare against the digest below — if the values match, the file has not been tampered with since issuing.',
|
||||||
|
audit_contract_number: 'Contract number',
|
||||||
|
audit_issued_at: 'Issued (sent to customer)',
|
||||||
|
audit_customer_section: 'Customer signature',
|
||||||
|
audit_admin_section: 'Contractor signature',
|
||||||
|
audit_integrity_section: 'File integrity hashes',
|
||||||
|
audit_signed_by: 'Name',
|
||||||
|
audit_signed_at: 'Timestamp (UTC)',
|
||||||
|
audit_ip: 'IP address',
|
||||||
|
audit_unsigned_sha: 'Original PDF SHA-256',
|
||||||
|
audit_signed_sha: 'Signed PDF SHA-256',
|
||||||
|
audit_footer: 'Generated by picpeak. This page is part of the contract — preserve all pages together.',
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
quote_title: 'Angebot',
|
||||||
|
invoice_title: 'Rechnung',
|
||||||
|
quote_number_label: 'Angebotsnummer',
|
||||||
|
invoice_number_label: 'Rechnungsnummer',
|
||||||
|
storno_title: 'Stornorechnung',
|
||||||
|
reference_cancels: 'Storno zu',
|
||||||
|
date: 'Datum',
|
||||||
|
quote_number: 'Angebot',
|
||||||
|
invoice_number: 'Rechnung',
|
||||||
|
valid_until: 'Gültig bis',
|
||||||
|
due_date: 'Fällig am',
|
||||||
|
salutation: 'Sehr geehrte Damen und Herren,',
|
||||||
|
lead_in_quote: 'gemäss unserer Absprache bieten wir wie folgt an:',
|
||||||
|
lead_in_invoice: 'gemäss unserer Vereinbarung berechnen wir wie folgt:',
|
||||||
|
table_pos: 'Pos.',
|
||||||
|
table_qty: 'Anzahl',
|
||||||
|
table_description: 'Beschreibung',
|
||||||
|
table_discount: 'Rabatt',
|
||||||
|
table_unit_price: 'Einzelpreis',
|
||||||
|
table_line_total: 'Summe',
|
||||||
|
totals_net: 'Betrag Netto',
|
||||||
|
totals_shipping: 'Versand',
|
||||||
|
totals_vat: 'ges. MwSt.',
|
||||||
|
totals_late_fee: 'Mahngebühr',
|
||||||
|
totals_grand: 'Gesamtbetrag',
|
||||||
|
payment_conditions: 'Zahlungsbedingungen',
|
||||||
|
iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:',
|
||||||
|
net_days_suffix: 'Tage nach Rechnungsdatum.',
|
||||||
|
skonto_label: 'Skonto',
|
||||||
|
skonto_phrase: '{percent}% Skonto bei Zahlung innerhalb von {days} Werktagen.',
|
||||||
|
skonto_amount_label: 'Betrag mit Skonto',
|
||||||
|
late_fee_note: 'Wegen Zahlungsverzug wurde eine Mahngebühr von {amount} berechnet.',
|
||||||
|
installment_due: 'fällig',
|
||||||
|
reference_label: 'Bezug',
|
||||||
|
reference_replaces: 'Ersetzt',
|
||||||
|
reference_dated: 'vom {date}',
|
||||||
|
page: 'Seite',
|
||||||
|
of: 'von',
|
||||||
|
page_of: 'Seite {current} von {total}',
|
||||||
|
epc_qr_title: 'Zum Bezahlen scannen (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Öffne deine Banking-App und scanne diesen Code, um die Überweisung vorauszufüllen.',
|
||||||
|
quote_response_intro: 'Sie können dieses Angebot hier annehmen oder ablehnen:',
|
||||||
|
accept_button: 'Angebot annehmen',
|
||||||
|
decline_button: 'Angebot ablehnen',
|
||||||
|
// Steuerliste — hand-translated.
|
||||||
|
tax_title: 'Steuerliste',
|
||||||
|
tax_period: 'Zeitraum',
|
||||||
|
tax_generated: 'Erstellt am',
|
||||||
|
tax_currency: 'Währung',
|
||||||
|
tax_col_no: 'Nr.',
|
||||||
|
tax_col_date: 'Datum',
|
||||||
|
tax_col_invoice: 'Rechnung',
|
||||||
|
tax_col_customer: 'Kunde',
|
||||||
|
tax_col_event: 'Anlass',
|
||||||
|
tax_col_vat_rate: 'MwSt-Satz',
|
||||||
|
tax_col_net: 'Netto',
|
||||||
|
tax_col_vat: 'MwSt.',
|
||||||
|
tax_col_total: 'Brutto',
|
||||||
|
tax_col_status: 'Status',
|
||||||
|
tax_col_skonto: 'Skonto',
|
||||||
|
tax_status_cancelled: 'Storniert',
|
||||||
|
tax_totals_by_rate: 'Summen nach MwSt-Satz',
|
||||||
|
tax_grand_total_net: 'Gesamt Netto',
|
||||||
|
tax_grand_total_vat: 'Gesamt MwSt.',
|
||||||
|
tax_grand_total_gross: 'Gesamt Brutto',
|
||||||
|
tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).',
|
||||||
|
tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.',
|
||||||
|
contract_title: 'Vertrag',
|
||||||
|
contract_number_label: 'Vertragsnummer',
|
||||||
|
section_basics: 'Vertragsgrundlagen',
|
||||||
|
section_scope: 'Leistungsumfang',
|
||||||
|
section_privacy: 'Persönlichkeitsrechte & Datenschutz',
|
||||||
|
section_commercial: 'Kaufmännisches',
|
||||||
|
section_nda: 'Vertraulichkeit',
|
||||||
|
section_closing: 'Schlussbestimmungen',
|
||||||
|
signature_customer: 'Auftraggeber',
|
||||||
|
signature_admin: 'Auftragnehmer',
|
||||||
|
signed_label_name: 'Name',
|
||||||
|
signed_label_date: 'Datum',
|
||||||
|
signed_label_place: 'Ort',
|
||||||
|
signed_label_signature: 'Unterschrift',
|
||||||
|
signed_at: 'Unterzeichnet am',
|
||||||
|
signature_page_title: 'Unterschriften',
|
||||||
|
signature_page_prompt: 'Beide Parteien bestätigen mit ihrer Unterschrift die Annahme der vorstehenden Bedingungen.',
|
||||||
|
audit_certificate_subject: 'Audit-Bescheinigung der Unterzeichnung',
|
||||||
|
audit_title: 'Audit-Trail der Unterzeichnung',
|
||||||
|
audit_intro: 'Die nachstehenden Belege wurden bei der Unterzeichnung automatisch erfasst. Zur Überprüfung der Dateiintegrität bilden Sie den SHA-256-Hash der Ihnen vorliegenden PDF-Datei und vergleichen ihn mit dem unten angegebenen Wert — bei Übereinstimmung wurde die Datei seit der Ausstellung nicht verändert.',
|
||||||
|
audit_contract_number: 'Vertragsnummer',
|
||||||
|
audit_issued_at: 'Ausgestellt (an Kunden gesendet)',
|
||||||
|
audit_customer_section: 'Unterschrift Auftraggeber',
|
||||||
|
audit_admin_section: 'Unterschrift Auftragnehmer',
|
||||||
|
audit_integrity_section: 'Datei-Integritätsprüfung',
|
||||||
|
audit_signed_by: 'Name',
|
||||||
|
audit_signed_at: 'Zeitstempel (UTC)',
|
||||||
|
audit_ip: 'IP-Adresse',
|
||||||
|
audit_unsigned_sha: 'SHA-256 Ursprungs-PDF',
|
||||||
|
audit_signed_sha: 'SHA-256 signiertes PDF',
|
||||||
|
audit_footer: 'Erstellt von picpeak. Diese Seite ist Bestandteil des Vertrags — bitte alle Seiten gemeinsam aufbewahren.',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
// Machine-translated, flagged for native review.
|
||||||
|
quote_title: 'Devis',
|
||||||
|
invoice_title: 'Facture',
|
||||||
|
quote_number_label: 'Numéro de devis',
|
||||||
|
invoice_number_label: 'Numéro de facture',
|
||||||
|
storno_title: 'Avoir',
|
||||||
|
reference_cancels: 'Annule',
|
||||||
|
date: 'Date',
|
||||||
|
quote_number: 'Devis',
|
||||||
|
invoice_number: 'Facture',
|
||||||
|
valid_until: 'Valable jusqu\'au',
|
||||||
|
due_date: 'Échéance',
|
||||||
|
salutation: 'Madame, Monsieur,',
|
||||||
|
lead_in_quote: 'conformément à notre accord, nous vous proposons ce qui suit :',
|
||||||
|
lead_in_invoice: 'conformément à notre accord, nous facturons ce qui suit :',
|
||||||
|
table_pos: 'Pos.',
|
||||||
|
table_qty: 'Qté',
|
||||||
|
table_description: 'Description',
|
||||||
|
table_discount: 'Rabais',
|
||||||
|
table_unit_price: 'Prix unitaire',
|
||||||
|
table_line_total: 'Total',
|
||||||
|
totals_net: 'Montant net',
|
||||||
|
totals_shipping: 'Frais d\'expédition',
|
||||||
|
totals_vat: 'TVA',
|
||||||
|
totals_late_fee: 'Frais de retard',
|
||||||
|
totals_grand: 'Total',
|
||||||
|
payment_conditions: 'Conditions de paiement',
|
||||||
|
iban_intro: 'Veuillez virer le montant sur le compte suivant :',
|
||||||
|
net_days_suffix: 'jours à compter de la date de facturation.',
|
||||||
|
skonto_label: 'Escompte',
|
||||||
|
skonto_phrase: '{percent}% d\'escompte si paiement dans les {days} jours ouvrables.',
|
||||||
|
skonto_amount_label: 'Montant avec escompte',
|
||||||
|
late_fee_note: 'Des frais de retard de {amount} ont été ajoutés.',
|
||||||
|
installment_due: 'échéance',
|
||||||
|
reference_label: 'Référence',
|
||||||
|
reference_replaces: 'Remplace',
|
||||||
|
reference_dated: 'du {date}',
|
||||||
|
page: 'Page',
|
||||||
|
of: 'sur',
|
||||||
|
page_of: 'Page {current} sur {total}',
|
||||||
|
epc_qr_title: 'Scannez pour payer (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Ouvrez votre application bancaire et scannez ce code pour pré-remplir le virement.',
|
||||||
|
quote_response_intro: 'Vous pouvez accepter ou refuser ce devis ici :',
|
||||||
|
accept_button: 'Accepter le devis',
|
||||||
|
decline_button: 'Refuser le devis',
|
||||||
|
// Tax report — machine-translated, flagged for native review.
|
||||||
|
tax_title: 'Rapport fiscal',
|
||||||
|
tax_period: 'Période',
|
||||||
|
tax_generated: 'Généré le',
|
||||||
|
tax_currency: 'Devise',
|
||||||
|
tax_col_no: 'N°',
|
||||||
|
tax_col_date: 'Date',
|
||||||
|
tax_col_invoice: 'Facture',
|
||||||
|
tax_col_customer: 'Client',
|
||||||
|
tax_col_event: 'Événement',
|
||||||
|
tax_col_vat_rate: 'Taux TVA',
|
||||||
|
tax_col_net: 'Net',
|
||||||
|
tax_col_vat: 'TVA',
|
||||||
|
tax_col_total: 'Brut',
|
||||||
|
tax_col_status: 'Statut',
|
||||||
|
tax_col_skonto: 'Escompte',
|
||||||
|
tax_status_cancelled: 'Annulée',
|
||||||
|
tax_totals_by_rate: 'Totaux par taux de TVA',
|
||||||
|
tax_grand_total_net: 'Total net',
|
||||||
|
tax_grand_total_vat: 'Total TVA',
|
||||||
|
tax_grand_total_gross: 'Total brut',
|
||||||
|
tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).',
|
||||||
|
tax_no_invoices: 'Aucune facture sur cette période.',
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
// Machine-translated, flagged for native review.
|
||||||
|
quote_title: 'Offerte',
|
||||||
|
invoice_title: 'Factuur',
|
||||||
|
quote_number_label: 'Offertenummer',
|
||||||
|
invoice_number_label: 'Factuurnummer',
|
||||||
|
storno_title: 'Creditfactuur',
|
||||||
|
reference_cancels: 'Annuleert',
|
||||||
|
date: 'Datum',
|
||||||
|
quote_number: 'Offerte',
|
||||||
|
invoice_number: 'Factuur',
|
||||||
|
valid_until: 'Geldig tot',
|
||||||
|
due_date: 'Vervaldatum',
|
||||||
|
salutation: 'Geachte heer/mevrouw,',
|
||||||
|
lead_in_quote: 'overeenkomstig onze afspraak doen wij u het volgende voorstel:',
|
||||||
|
lead_in_invoice: 'overeenkomstig onze afspraak factureren wij het volgende:',
|
||||||
|
table_pos: 'Pos.',
|
||||||
|
table_qty: 'Aantal',
|
||||||
|
table_description: 'Beschrijving',
|
||||||
|
table_discount: 'Korting',
|
||||||
|
table_unit_price: 'Prijs per stuk',
|
||||||
|
table_line_total: 'Totaal',
|
||||||
|
totals_net: 'Netto bedrag',
|
||||||
|
totals_shipping: 'Verzending',
|
||||||
|
totals_vat: 'BTW',
|
||||||
|
totals_late_fee: 'Aanmaningskosten',
|
||||||
|
totals_grand: 'Totaal',
|
||||||
|
payment_conditions: 'Betalingsvoorwaarden',
|
||||||
|
iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:',
|
||||||
|
net_days_suffix: 'dagen na factuurdatum.',
|
||||||
|
skonto_label: 'Betalingskorting',
|
||||||
|
skonto_phrase: '{percent}% korting bij betaling binnen {days} werkdagen.',
|
||||||
|
skonto_amount_label: 'Bedrag met korting',
|
||||||
|
late_fee_note: 'Wegens te late betaling is een toeslag van {amount} toegevoegd.',
|
||||||
|
installment_due: 'vervalt op',
|
||||||
|
reference_label: 'Referentie',
|
||||||
|
reference_replaces: 'Vervangt',
|
||||||
|
reference_dated: 'van {date}',
|
||||||
|
page: 'Pagina',
|
||||||
|
of: 'van',
|
||||||
|
page_of: 'Pagina {current} van {total}',
|
||||||
|
epc_qr_title: 'Scan om te betalen (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Open je bank-app en scan deze code om de overschrijving in te vullen.',
|
||||||
|
quote_response_intro: 'U kunt deze offerte hier accepteren of weigeren:',
|
||||||
|
accept_button: 'Offerte accepteren',
|
||||||
|
decline_button: 'Offerte weigeren',
|
||||||
|
// Tax report — machine-translated, flagged for native review.
|
||||||
|
tax_title: 'Belastingrapport',
|
||||||
|
tax_period: 'Periode',
|
||||||
|
tax_generated: 'Gegenereerd op',
|
||||||
|
tax_currency: 'Valuta',
|
||||||
|
tax_col_no: 'Nr.',
|
||||||
|
tax_col_date: 'Datum',
|
||||||
|
tax_col_invoice: 'Factuur',
|
||||||
|
tax_col_customer: 'Klant',
|
||||||
|
tax_col_event: 'Evenement',
|
||||||
|
tax_col_vat_rate: 'Btw-tarief',
|
||||||
|
tax_col_net: 'Netto',
|
||||||
|
tax_col_vat: 'Btw',
|
||||||
|
tax_col_total: 'Bruto',
|
||||||
|
tax_col_status: 'Status',
|
||||||
|
tax_col_skonto: 'Korting',
|
||||||
|
tax_status_cancelled: 'Geannuleerd',
|
||||||
|
tax_totals_by_rate: 'Totalen per btw-tarief',
|
||||||
|
tax_grand_total_net: 'Totaal netto',
|
||||||
|
tax_grand_total_vat: 'Totaal btw',
|
||||||
|
tax_grand_total_gross: 'Totaal bruto',
|
||||||
|
tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).',
|
||||||
|
tax_no_invoices: 'Geen facturen in deze periode.',
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
// Machine-translated, flagged for native review.
|
||||||
|
quote_title: 'Orçamento',
|
||||||
|
invoice_title: 'Fatura',
|
||||||
|
quote_number_label: 'Número do orçamento',
|
||||||
|
invoice_number_label: 'Número da fatura',
|
||||||
|
storno_title: 'Nota de crédito',
|
||||||
|
reference_cancels: 'Cancela',
|
||||||
|
date: 'Data',
|
||||||
|
quote_number: 'Orçamento',
|
||||||
|
invoice_number: 'Fatura',
|
||||||
|
valid_until: 'Válido até',
|
||||||
|
due_date: 'Vencimento',
|
||||||
|
salutation: 'Prezados Senhores,',
|
||||||
|
lead_in_quote: 'conforme combinado, oferecemos o seguinte:',
|
||||||
|
lead_in_invoice: 'conforme combinado, faturamos o seguinte:',
|
||||||
|
table_pos: 'Pos.',
|
||||||
|
table_qty: 'Qtde.',
|
||||||
|
table_description: 'Descrição',
|
||||||
|
table_discount: 'Desconto',
|
||||||
|
table_unit_price: 'Preço unitário',
|
||||||
|
table_line_total: 'Total',
|
||||||
|
totals_net: 'Valor líquido',
|
||||||
|
totals_shipping: 'Envio',
|
||||||
|
totals_vat: 'IVA',
|
||||||
|
totals_late_fee: 'Taxa de atraso',
|
||||||
|
totals_grand: 'Total',
|
||||||
|
payment_conditions: 'Condições de pagamento',
|
||||||
|
iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:',
|
||||||
|
net_days_suffix: 'dias após a data da fatura.',
|
||||||
|
skonto_label: 'Desconto por pagamento antecipado',
|
||||||
|
skonto_phrase: '{percent}% de desconto se pago em {days} dias úteis.',
|
||||||
|
skonto_amount_label: 'Valor com desconto',
|
||||||
|
late_fee_note: 'Uma taxa de atraso de {amount} foi adicionada.',
|
||||||
|
installment_due: 'vence em',
|
||||||
|
reference_label: 'Referência',
|
||||||
|
reference_replaces: 'Substitui',
|
||||||
|
reference_dated: 'de {date}',
|
||||||
|
page: 'Página',
|
||||||
|
of: 'de',
|
||||||
|
page_of: 'Página {current} de {total}',
|
||||||
|
epc_qr_title: 'Digitalize para pagar (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Abra o seu app bancário e digitalize este código para pré-preencher a transferência.',
|
||||||
|
quote_response_intro: 'Você pode aceitar ou recusar este orçamento aqui:',
|
||||||
|
accept_button: 'Aceitar orçamento',
|
||||||
|
decline_button: 'Recusar orçamento',
|
||||||
|
// Tax report — machine-translated, flagged for native review.
|
||||||
|
tax_title: 'Relatório fiscal',
|
||||||
|
tax_period: 'Período',
|
||||||
|
tax_generated: 'Gerado em',
|
||||||
|
tax_currency: 'Moeda',
|
||||||
|
tax_col_no: 'N.º',
|
||||||
|
tax_col_date: 'Data',
|
||||||
|
tax_col_invoice: 'Fatura',
|
||||||
|
tax_col_customer: 'Cliente',
|
||||||
|
tax_col_event: 'Evento',
|
||||||
|
tax_col_vat_rate: 'Taxa IVA',
|
||||||
|
tax_col_net: 'Líquido',
|
||||||
|
tax_col_vat: 'IVA',
|
||||||
|
tax_col_total: 'Bruto',
|
||||||
|
tax_col_status: 'Estado',
|
||||||
|
tax_col_skonto: 'Desconto',
|
||||||
|
tax_status_cancelled: 'Cancelada',
|
||||||
|
tax_totals_by_rate: 'Totais por taxa de IVA',
|
||||||
|
tax_grand_total_net: 'Total líquido',
|
||||||
|
tax_grand_total_vat: 'Total IVA',
|
||||||
|
tax_grand_total_gross: 'Total bruto',
|
||||||
|
tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).',
|
||||||
|
tax_no_invoices: 'Sem faturas neste período.',
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
// Machine-translated, flagged for native review.
|
||||||
|
quote_title: 'Коммерческое предложение',
|
||||||
|
invoice_title: 'Счёт',
|
||||||
|
quote_number_label: 'Номер предложения',
|
||||||
|
invoice_number_label: 'Номер счёта',
|
||||||
|
storno_title: 'Сторно-счёт',
|
||||||
|
reference_cancels: 'Сторно к',
|
||||||
|
date: 'Дата',
|
||||||
|
quote_number: 'Предложение',
|
||||||
|
invoice_number: 'Счёт',
|
||||||
|
valid_until: 'Действительно до',
|
||||||
|
due_date: 'Срок оплаты',
|
||||||
|
salutation: 'Уважаемые дамы и господа!',
|
||||||
|
lead_in_quote: 'согласно нашей договорённости, предлагаем следующее:',
|
||||||
|
lead_in_invoice: 'согласно нашей договорённости, выставляем счёт на следующее:',
|
||||||
|
table_pos: 'Поз.',
|
||||||
|
table_qty: 'Кол-во',
|
||||||
|
table_description: 'Описание',
|
||||||
|
table_discount: 'Скидка',
|
||||||
|
table_unit_price: 'Цена за ед.',
|
||||||
|
table_line_total: 'Сумма',
|
||||||
|
totals_net: 'Сумма нетто',
|
||||||
|
totals_shipping: 'Доставка',
|
||||||
|
totals_vat: 'НДС',
|
||||||
|
totals_late_fee: 'Пеня за просрочку',
|
||||||
|
totals_grand: 'Итого',
|
||||||
|
payment_conditions: 'Условия оплаты',
|
||||||
|
iban_intro: 'Просим перевести сумму на следующий банковский счёт:',
|
||||||
|
net_days_suffix: 'дней с даты счёта.',
|
||||||
|
skonto_label: 'Скидка за досрочную оплату',
|
||||||
|
skonto_phrase: 'Скидка {percent}% при оплате в течение {days} рабочих дней.',
|
||||||
|
skonto_amount_label: 'Сумма со скидкой',
|
||||||
|
late_fee_note: 'Добавлена пеня за просрочку: {amount}.',
|
||||||
|
installment_due: 'к оплате',
|
||||||
|
reference_label: 'Ссылка',
|
||||||
|
reference_replaces: 'Заменяет',
|
||||||
|
reference_dated: 'от {date}',
|
||||||
|
page: 'Стр.',
|
||||||
|
of: 'из',
|
||||||
|
page_of: 'Стр. {current} из {total}',
|
||||||
|
epc_qr_title: 'Сканируйте для оплаты (SEPA)',
|
||||||
|
epc_qr_subtitle: 'Откройте банковское приложение и отсканируйте этот код, чтобы предзаполнить перевод.',
|
||||||
|
quote_response_intro: 'Вы можете принять или отклонить это предложение здесь:',
|
||||||
|
accept_button: 'Принять предложение',
|
||||||
|
decline_button: 'Отклонить предложение',
|
||||||
|
// Tax report — machine-translated, flagged for native review.
|
||||||
|
tax_title: 'Налоговый отчёт',
|
||||||
|
tax_period: 'Период',
|
||||||
|
tax_generated: 'Создан',
|
||||||
|
tax_currency: 'Валюта',
|
||||||
|
tax_col_no: '№',
|
||||||
|
tax_col_date: 'Дата',
|
||||||
|
tax_col_invoice: 'Счёт',
|
||||||
|
tax_col_customer: 'Клиент',
|
||||||
|
tax_col_event: 'Событие',
|
||||||
|
tax_col_vat_rate: 'Ставка НДС',
|
||||||
|
tax_col_net: 'Нетто',
|
||||||
|
tax_col_vat: 'НДС',
|
||||||
|
tax_col_total: 'Брутто',
|
||||||
|
tax_col_status: 'Статус',
|
||||||
|
tax_col_skonto: 'Скидка',
|
||||||
|
tax_status_cancelled: 'Аннулирован',
|
||||||
|
tax_totals_by_rate: 'Итоги по ставкам НДС',
|
||||||
|
tax_grand_total_net: 'Итого нетто',
|
||||||
|
tax_grand_total_vat: 'Итого НДС',
|
||||||
|
tax_grand_total_gross: 'Итого брутто',
|
||||||
|
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
|
||||||
|
tax_no_invoices: 'Нет счетов за этот период.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(locale, key, vars = {}) {
|
||||||
|
const dict = LABELS[locale] || LABELS.en;
|
||||||
|
let str = dict[key] || LABELS.en[key] || key;
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { t, LABELS };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* Contract-PDF stamp service.
|
||||||
|
*
|
||||||
|
* Replaces the previous re-render-on-every-signature approach with
|
||||||
|
* the industry-standard pattern: the unsigned contract PDF is
|
||||||
|
* rendered once at send time and stays byte-immutable from then on.
|
||||||
|
* Each signature event opens that PDF with pdf-lib, overlays the
|
||||||
|
* signature PNG at the fixed coordinates defined in
|
||||||
|
* pdfService.CONTRACT_SIGNATURE_LAYOUT, then writes the result to a
|
||||||
|
* new timestamped file. Same model DocuSign / Adobe Sign / HelloSign
|
||||||
|
* use.
|
||||||
|
*
|
||||||
|
* Why this matters for audit defence:
|
||||||
|
* - The customer's signed PDF = the original PDF + their signature
|
||||||
|
* stamp + nothing else. Bytes the customer saw at signing time
|
||||||
|
* pass through unchanged into the signed file.
|
||||||
|
* - No render-code drift between sign events; later layout
|
||||||
|
* tweaks to renderContractToBuffer don't retroactively change
|
||||||
|
* what already-signed PDFs look like.
|
||||||
|
* - The audit certificate (timestamps, IPs, hashes) is a
|
||||||
|
* separate sibling document — not embedded in the signed
|
||||||
|
* contract PDF — so the operator can verify each independently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const PDFKit = require('pdfkit');
|
||||||
|
const { PDFDocument } = require('pdf-lib');
|
||||||
|
const pdfService = require('./pdfService');
|
||||||
|
// Resolve these at function-call time, not at module-load time, so the
|
||||||
|
// module remains loadable even when pdfService is mocked in unit tests
|
||||||
|
// (the mock stubs only renderContractToBuffer). Each function reads
|
||||||
|
// the live values from pdfService at the top of its body.
|
||||||
|
function pdfConsts() {
|
||||||
|
return {
|
||||||
|
L: pdfService.CONTRACT_SIGNATURE_LAYOUT,
|
||||||
|
PAGE: pdfService.PAGE,
|
||||||
|
FONT_BODY: pdfService.FONT_BODY,
|
||||||
|
FONT_BOLD: pdfService.FONT_BOLD,
|
||||||
|
t: pdfService._internal && pdfService._internal.t,
|
||||||
|
// formatDate respects the `general_date_format` app setting when
|
||||||
|
// a dateFormat arg is passed; with no arg it defaults to the
|
||||||
|
// European DD.MM.YYYY shape (the operator's locale). Used for the
|
||||||
|
// "Datum: ..." line under each signature stamp.
|
||||||
|
formatDate: pdfService._internal && pdfService._internal.formatDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
function sha256OfBuffer(buf) {
|
||||||
|
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDFKit-style coordinates (top-left origin, y increases
|
||||||
|
* downward) to pdf-lib coordinates (bottom-left origin, y increases
|
||||||
|
* upward). Both libraries use PDF's native point unit.
|
||||||
|
*/
|
||||||
|
function pdfkitToPdfLib(pageHeight, x, y, w, h) {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y: pageHeight - y - h,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp a signature image onto an existing contract PDF.
|
||||||
|
*
|
||||||
|
* - `pdfBuffer` is the Buffer of the PDF we're stamping into. Either
|
||||||
|
* the originally-rendered unsigned PDF (first stamp) or a
|
||||||
|
* previously-stamped version (second stamp adds the admin's
|
||||||
|
* signature on top of the customer-stamped PDF).
|
||||||
|
* - `signaturePngPath` is the on-disk path of the canvas PNG to
|
||||||
|
* embed. The file must exist; caller already validated this.
|
||||||
|
* - `role` is 'customer' or 'admin' — selects the left/right box.
|
||||||
|
* - `caption` is the typed name + ISO date string drawn under the
|
||||||
|
* image so the visual artifact matches what the unsigned PDF
|
||||||
|
* showed as empty caption rows.
|
||||||
|
*
|
||||||
|
* Returns a Buffer of the new PDF. Does NOT touch the input buffer
|
||||||
|
* or the input file.
|
||||||
|
*/
|
||||||
|
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
|
||||||
|
const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts();
|
||||||
|
if (!Buffer.isBuffer(pdfBuffer)) {
|
||||||
|
throw new Error('stampSignature: pdfBuffer must be a Buffer');
|
||||||
|
}
|
||||||
|
if (!signaturePngPath || !fs.existsSync(signaturePngPath)) {
|
||||||
|
throw new Error(`stampSignature: signature PNG not found at ${signaturePngPath}`);
|
||||||
|
}
|
||||||
|
if (!['customer', 'admin'].includes(role)) {
|
||||||
|
throw new Error(`stampSignature: role must be 'customer' or 'admin', got '${role}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||||
|
const pngBytes = fs.readFileSync(signaturePngPath);
|
||||||
|
let pngImage;
|
||||||
|
try {
|
||||||
|
pngImage = await pdfDoc.embedPng(pngBytes);
|
||||||
|
} catch (err) {
|
||||||
|
// pdf-lib throws InvalidPNGError for files that aren't valid PNG.
|
||||||
|
// Try JPEG as a fallback (the canvas could be saved as JPEG too).
|
||||||
|
try {
|
||||||
|
pngImage = await pdfDoc.embedJpg(pngBytes);
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`stampSignature: signature file at ${signaturePngPath} is neither valid PNG nor JPEG`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdf-lib pages are 0-indexed. The signature page is the last page
|
||||||
|
// of the unsigned PDF (added by renderContractToBuffer just before
|
||||||
|
// the page-number stamp).
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const sigPage = pages[pages.length - 1];
|
||||||
|
const { height: pageH } = sigPage.getSize();
|
||||||
|
|
||||||
|
// Origin coordinates for the box in PDFKit space. Pick by role.
|
||||||
|
const boxX = role === 'customer' ? L.customerX : L.adminX;
|
||||||
|
const boxY = L.boxY;
|
||||||
|
const boxW = L.boxWidth;
|
||||||
|
const boxH = L.boxHeight;
|
||||||
|
|
||||||
|
// The signature image fits inside the box with 4pt padding on each
|
||||||
|
// side. We preserve the aspect ratio by scaling the image to fit,
|
||||||
|
// then centring it.
|
||||||
|
const padding = 4;
|
||||||
|
const innerW = boxW - 2 * padding;
|
||||||
|
const innerH = boxH - 2 * padding;
|
||||||
|
const imgW = pngImage.width;
|
||||||
|
const imgH = pngImage.height;
|
||||||
|
const scale = Math.min(innerW / imgW, innerH / imgH);
|
||||||
|
const drawW = imgW * scale;
|
||||||
|
const drawH = imgH * scale;
|
||||||
|
// Centre inside the inner rect.
|
||||||
|
const drawXPdfkit = boxX + padding + (innerW - drawW) / 2;
|
||||||
|
const drawYPdfkit = boxY + padding + (innerH - drawH) / 2;
|
||||||
|
const conv = pdfkitToPdfLib(pageH, drawXPdfkit, drawYPdfkit, drawW, drawH);
|
||||||
|
|
||||||
|
sigPage.drawImage(pngImage, conv);
|
||||||
|
|
||||||
|
// Caption — fill in the "Name: ___" and "Date: ___" rows under
|
||||||
|
// the box. The unsigned PDF left these empty; we overwrite by
|
||||||
|
// drawing white rectangles over the empty rows then printing the
|
||||||
|
// filled-in values on top. Same coords as the unsigned render's
|
||||||
|
// captionY = boxY + boxHeight + 6.
|
||||||
|
if (caption && (caption.name || caption.signedAt)) {
|
||||||
|
const captionYPdfkit = boxY + boxH + 6;
|
||||||
|
// Use the shared formatDate helper so the "Datum: ..." line
|
||||||
|
// matches the locale-aware DD.MM.YYYY format the rest of the
|
||||||
|
// contract PDF uses (e.g. issue-date headline). Caller may pass
|
||||||
|
// a custom dateFormat via caption.dateFormat for per-document
|
||||||
|
// overrides; without it formatDate defaults to DD.MM.YYYY.
|
||||||
|
const lines = [
|
||||||
|
`${caption.nameLabel || 'Name'}: ${caption.name || ''}`,
|
||||||
|
`${caption.dateLabel || 'Date'}: ${caption.signedAt && formatDate
|
||||||
|
? formatDate(caption.signedAt, caption.dateFormat)
|
||||||
|
: ''}`,
|
||||||
|
];
|
||||||
|
// Overdraw a white rectangle so we replace the unsigned-page's
|
||||||
|
// empty captions cleanly. PDFKit + pdf-lib both lay glyphs over
|
||||||
|
// existing content rather than replacing, so without this the
|
||||||
|
// old "Name: " would still show through.
|
||||||
|
const captionRect = pdfkitToPdfLib(pageH, boxX, captionYPdfkit - 2, boxW, 28);
|
||||||
|
sigPage.drawRectangle({ ...captionRect, color: pdfLibRgb(1, 1, 1) });
|
||||||
|
|
||||||
|
// Embed Helvetica (pdf-lib's built-in font). 9pt to match the
|
||||||
|
// unsigned render's caption size.
|
||||||
|
const StandardFonts = require('pdf-lib').StandardFonts;
|
||||||
|
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const fontSize = 9;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const lineY = captionYPdfkit + i * 12;
|
||||||
|
const conv2 = pdfkitToPdfLib(pageH, boxX, lineY, boxW, fontSize);
|
||||||
|
sigPage.drawText(lines[i], {
|
||||||
|
x: conv2.x,
|
||||||
|
y: conv2.y,
|
||||||
|
size: fontSize,
|
||||||
|
font: helv,
|
||||||
|
color: pdfLibRgb(0, 0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outBytes = await pdfDoc.save();
|
||||||
|
return Buffer.from(outBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdf-lib expects rgb() instances. Importing the helper lazily so
|
||||||
|
// the function works whether pdf-lib resolves it as a named export or
|
||||||
|
// a method on the default object across versions.
|
||||||
|
let _rgbFn = null;
|
||||||
|
function pdfLibRgb(r, g, b) {
|
||||||
|
if (!_rgbFn) {
|
||||||
|
const m = require('pdf-lib');
|
||||||
|
_rgbFn = m.rgb || ((rr, gg, bb) => ({ type: 'RGB', red: rr, green: gg, blue: bb }));
|
||||||
|
}
|
||||||
|
return _rgbFn(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the audit certificate — a standalone single-page (or 2-page
|
||||||
|
* if it grows) PDF that records timestamps, IPs, SHA-256 hashes, and
|
||||||
|
* the actor names for every signature event on the contract.
|
||||||
|
*
|
||||||
|
* Used as a sibling document to the signed contract PDF. Both are
|
||||||
|
* attached to the contract_fully_signed email and stored on the
|
||||||
|
* contract row so either party can fetch each independently.
|
||||||
|
*
|
||||||
|
* The certificate references the signed contract PDF by hash —
|
||||||
|
* verifying the certificate authentic + re-hashing the contract PDF
|
||||||
|
* is the integrity check.
|
||||||
|
*
|
||||||
|
* Returns { buffer, sha256 }.
|
||||||
|
*/
|
||||||
|
async function renderAuditCertificate({ contract, customer, admin, locale = 'de' }) {
|
||||||
|
const { PAGE, FONT_BODY, FONT_BOLD, t } = pdfConsts();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFKit({
|
||||||
|
size: 'A4',
|
||||||
|
bufferPages: true,
|
||||||
|
margins: {
|
||||||
|
top: PAGE.marginTop, bottom: PAGE.marginBottom,
|
||||||
|
left: PAGE.marginLeft, right: PAGE.marginRight,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
Title: `${contract.contract_number || 'Contract'}_audit_certificate`,
|
||||||
|
Author: 'picpeak',
|
||||||
|
Subject: t(locale, 'audit_certificate_subject'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (c) => chunks.push(c));
|
||||||
|
doc.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
resolve({ buffer, sha256: sha256OfBuffer(buffer) });
|
||||||
|
});
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
|
||||||
|
|
||||||
|
let y = PAGE.marginTop;
|
||||||
|
|
||||||
|
doc.font(doc._fonts.bold).fontSize(18).fillColor('#000');
|
||||||
|
doc.text(t(locale, 'audit_title'), PAGE.marginLeft, y, {
|
||||||
|
width: PAGE.contentWidth,
|
||||||
|
});
|
||||||
|
y = doc.y + 6;
|
||||||
|
doc.strokeColor('#888').lineWidth(0.5)
|
||||||
|
.moveTo(PAGE.marginLeft, y).lineTo(PAGE.marginLeft + PAGE.contentWidth, y).stroke();
|
||||||
|
y += 14;
|
||||||
|
|
||||||
|
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
|
||||||
|
doc.text(t(locale, 'audit_intro'), PAGE.marginLeft, y, {
|
||||||
|
width: PAGE.contentWidth, align: 'left',
|
||||||
|
});
|
||||||
|
y = doc.y + 14;
|
||||||
|
|
||||||
|
const labelW = 200;
|
||||||
|
const valueW = PAGE.contentWidth - labelW;
|
||||||
|
function row(labelKey, value) {
|
||||||
|
if (!value) return;
|
||||||
|
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
|
||||||
|
doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
|
||||||
|
width: labelW, lineBreak: false,
|
||||||
|
});
|
||||||
|
doc.font(doc._fonts.body).fontSize(9).fillColor('#000');
|
||||||
|
doc.text(String(value), PAGE.marginLeft + labelW, y, {
|
||||||
|
width: valueW, align: 'left',
|
||||||
|
});
|
||||||
|
y = Math.max(y + 12, doc.y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
row('audit_contract_number', contract.contract_number);
|
||||||
|
row('audit_issued_at', contract.sent_at
|
||||||
|
? new Date(contract.sent_at).toISOString()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (customer && (customer.name || customer.signedAt)) {
|
||||||
|
y += 6;
|
||||||
|
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
|
||||||
|
doc.text(t(locale, 'audit_customer_section'), PAGE.marginLeft, y);
|
||||||
|
y = doc.y + 4;
|
||||||
|
row('audit_signed_by', customer.name);
|
||||||
|
row('audit_signed_at', customer.signedAt ? new Date(customer.signedAt).toISOString() : null);
|
||||||
|
row('audit_ip', customer.ip);
|
||||||
|
}
|
||||||
|
if (admin && (admin.name || admin.signedAt)) {
|
||||||
|
y += 6;
|
||||||
|
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
|
||||||
|
doc.text(t(locale, 'audit_admin_section'), PAGE.marginLeft, y);
|
||||||
|
y = doc.y + 4;
|
||||||
|
row('audit_signed_by', admin.name);
|
||||||
|
row('audit_signed_at', admin.signedAt ? new Date(admin.signedAt).toISOString() : null);
|
||||||
|
row('audit_ip', admin.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.pdf_sha256 || contract.signed_pdf_sha256) {
|
||||||
|
y += 8;
|
||||||
|
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
|
||||||
|
doc.text(t(locale, 'audit_integrity_section'), PAGE.marginLeft, y);
|
||||||
|
y = doc.y + 4;
|
||||||
|
row('audit_unsigned_sha', contract.pdf_sha256);
|
||||||
|
row('audit_signed_sha', contract.signed_pdf_sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
y += 14;
|
||||||
|
doc.font(doc._fonts.body).fontSize(8).fillColor('#666');
|
||||||
|
doc.text(t(locale, 'audit_footer'), PAGE.marginLeft, y, {
|
||||||
|
width: PAGE.contentWidth, align: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a sequence of signature stamps to a contract PDF buffer.
|
||||||
|
* Each stamp is `{ signaturePngPath, role, caption }`. Returns the
|
||||||
|
* final buffer + its SHA-256 hash.
|
||||||
|
*
|
||||||
|
* Single-pass so file IO happens once per stamp pair. Caller orders
|
||||||
|
* the array (customer first, admin second) per the desired
|
||||||
|
* provenance chain.
|
||||||
|
*/
|
||||||
|
async function stampSignatures(originalPdfBuffer, stamps) {
|
||||||
|
let buffer = originalPdfBuffer;
|
||||||
|
for (const stamp of stamps) {
|
||||||
|
if (!stamp.signaturePngPath) continue;
|
||||||
|
try {
|
||||||
|
buffer = await stampSignature({
|
||||||
|
pdfBuffer: buffer,
|
||||||
|
signaturePngPath: stamp.signaturePngPath,
|
||||||
|
role: stamp.role,
|
||||||
|
caption: stamp.caption,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('stampSignatures: failed to apply stamp', {
|
||||||
|
role: stamp.role,
|
||||||
|
signaturePngPath: stamp.signaturePngPath,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
// Skip the failed stamp but keep going — better to produce a
|
||||||
|
// PDF missing one signature than to lose the whole document.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { buffer, sha256: sha256OfBuffer(buffer) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
stampSignature,
|
||||||
|
stampSignatures,
|
||||||
|
renderAuditCertificate,
|
||||||
|
_internal: { pdfkitToPdfLib, sha256OfBuffer },
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
|||||||
|
/**
|
||||||
|
* taxReportService — period-scoped revenue listing for tax filing.
|
||||||
|
*
|
||||||
|
* Pulls every revenue-relevant invoice in [from, to] (accrual basis,
|
||||||
|
* keyed on `issue_date`) and returns rows + totals broken down by
|
||||||
|
* VAT rate. Cancelled invoices stay in the row list (DE/CH/AT audit
|
||||||
|
* trail requires a gap-free invoice-number sequence) but are excluded
|
||||||
|
* from the totals math.
|
||||||
|
*
|
||||||
|
* Late fees: the user opted to include them in the totals. We split
|
||||||
|
* each invoice's `late_fee_amount_minor` proportionally using the
|
||||||
|
* invoice's own VAT rate:
|
||||||
|
* lateFeeNet = round(late_fee_amount_minor / (1 + vat_rate/100))
|
||||||
|
* lateFeeVat = late_fee_amount_minor − lateFeeNet
|
||||||
|
* and add those onto the stored `net_amount_minor` / `vat_amount_minor`
|
||||||
|
* before reporting. Invoices without a late fee → math collapses to
|
||||||
|
* the stored values.
|
||||||
|
*
|
||||||
|
* Returned shape (see getTaxReport):
|
||||||
|
* {
|
||||||
|
* rows: [{ id, invoiceNumber, issueDate, currency,
|
||||||
|
* vatRate, customerLabel, eventName,
|
||||||
|
* netMinor, vatMinor, totalMinor,
|
||||||
|
* isCancelled, replacedByInvoiceNumber }, …],
|
||||||
|
* totalsByVatRate: [{ vatRate, netMinor, vatMinor, totalMinor }, …],
|
||||||
|
* grandTotalNet: Number (minor units),
|
||||||
|
* grandTotalVat: Number (minor units),
|
||||||
|
* grandTotal: Number (minor units),
|
||||||
|
* cancelledCount: Number,
|
||||||
|
* currency: String,
|
||||||
|
* period: { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' },
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Counterpart renderers (renderTaxReportPdf / renderTaxReportCsv)
|
||||||
|
* land in commit 3 alongside the routes — keeping the service pure
|
||||||
|
* data-shaping for this commit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db, withRetry } = require('../database/db');
|
||||||
|
const pdfService = require('./pdfService');
|
||||||
|
const businessProfileService = require('./businessProfileService');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { t } = require('./pdf-i18n');
|
||||||
|
const { formatMinor, formatDate } = pdfService._internal;
|
||||||
|
|
||||||
|
// Rows we WANT to surface in the tax report. `cancelled` is included
|
||||||
|
// for audit visibility; the totals math filters it out separately.
|
||||||
|
const REPORTABLE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery', 'cancelled'];
|
||||||
|
|
||||||
|
// D.2 — `ensureInt` consolidated into utils/numericHelpers.
|
||||||
|
const { ensureInt } = require('../utils/numericHelpers');
|
||||||
|
|
||||||
|
function ensureRate(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return 0;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the customer label we show in the table. Prefers company
|
||||||
|
* name (most invoices in this workflow are B2B), falls back to
|
||||||
|
* "First Last", then display_name, then email. Mirrors how the bills
|
||||||
|
* list page picks a label so the two views feel consistent.
|
||||||
|
*/
|
||||||
|
function buildCustomerLabel(row) {
|
||||||
|
if (row.customer_company_name && String(row.customer_company_name).trim()) {
|
||||||
|
return String(row.customer_company_name).trim();
|
||||||
|
}
|
||||||
|
const first = row.customer_first_name ? String(row.customer_first_name).trim() : '';
|
||||||
|
const last = row.customer_last_name ? String(row.customer_last_name).trim() : '';
|
||||||
|
const fullName = `${first} ${last}`.trim();
|
||||||
|
if (fullName) return fullName;
|
||||||
|
if (row.customer_display_name) return String(row.customer_display_name).trim();
|
||||||
|
if (row.customer_email) return String(row.customer_email).trim();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a late-fee gross amount into (net, vat) components using the
|
||||||
|
* invoice's own VAT rate. Rounding direction matches how we render
|
||||||
|
* money throughout the system: half-to-even on the net portion,
|
||||||
|
* remainder lands in VAT so net + vat = grossInput exactly.
|
||||||
|
*
|
||||||
|
* grossUpLateFee(2500, 7.7) → { net: 2321, vat: 179 } // 25.00 → 23.21 + 1.79
|
||||||
|
* grossUpLateFee(2500, 0) → { net: 2500, vat: 0 } // no VAT, fee is pure net
|
||||||
|
*/
|
||||||
|
function grossUpLateFee(grossMinor, vatRatePercent) {
|
||||||
|
const fee = ensureInt(grossMinor);
|
||||||
|
if (fee <= 0) return { net: 0, vat: 0 };
|
||||||
|
const rate = ensureRate(vatRatePercent);
|
||||||
|
if (rate <= 0) return { net: fee, vat: 0 };
|
||||||
|
const net = Math.round(fee / (1 + rate / 100));
|
||||||
|
const vat = fee - net;
|
||||||
|
return { net, vat };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the late-fee gross-up to a raw DB row and return the values
|
||||||
|
* we'll show + sum in the report. Net + VAT are the stored amounts
|
||||||
|
* PLUS the late-fee components; total stays at `total_amount_minor`
|
||||||
|
* (already includes the late fee).
|
||||||
|
*/
|
||||||
|
function computeReportedAmounts(row) {
|
||||||
|
const baseNet = ensureInt(row.net_amount_minor);
|
||||||
|
const baseVat = ensureInt(row.vat_amount_minor);
|
||||||
|
const total = ensureInt(row.total_amount_minor);
|
||||||
|
const { net: lateNet, vat: lateVat } = grossUpLateFee(row.late_fee_amount_minor, row.vat_rate);
|
||||||
|
return {
|
||||||
|
netMinor: baseNet + lateNet,
|
||||||
|
vatMinor: baseVat + lateVat,
|
||||||
|
totalMinor: total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which replacement invoice (if any) was issued for each
|
||||||
|
* cancelled row. Used for the "Bezug → R-2026-0043" badge in the UI
|
||||||
|
* and PDF. Single batched query, no N+1.
|
||||||
|
*/
|
||||||
|
async function loadReplacementsMap(cancelledIds) {
|
||||||
|
if (!cancelledIds.length) return new Map();
|
||||||
|
const successors = await db('invoices')
|
||||||
|
.whereIn('replaces_invoice_id', cancelledIds)
|
||||||
|
.select('replaces_invoice_id', 'invoice_number');
|
||||||
|
const map = new Map();
|
||||||
|
for (const s of successors) {
|
||||||
|
map.set(s.replaces_invoice_id, s.invoice_number);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Skonto state per invoice from `invoice_payment_log`
|
||||||
|
* (migration 126). Returns Map<invoice_id, { applied, amountMinor }>.
|
||||||
|
* An invoice is considered Skonto-applied if ANY of its payment-log
|
||||||
|
* rows carries the flag — admins occasionally split the discounted
|
||||||
|
* total across multiple rows (e.g. retainer + final).
|
||||||
|
*
|
||||||
|
* Single batched query, no N+1. Empty map when the input list is
|
||||||
|
* empty so the main path can skip the lookup entirely on empty
|
||||||
|
* periods.
|
||||||
|
*/
|
||||||
|
async function loadSkontoMap(invoiceIds) {
|
||||||
|
if (!invoiceIds.length) return new Map();
|
||||||
|
const rows = await db('invoice_payment_log')
|
||||||
|
.whereIn('invoice_id', invoiceIds)
|
||||||
|
.select('invoice_id', 'skonto_applied', 'skonto_amount_minor');
|
||||||
|
const map = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const flag = r.skonto_applied === true || r.skonto_applied === 1;
|
||||||
|
const amt = Number(r.skonto_amount_minor || 0);
|
||||||
|
const cur = map.get(r.invoice_id) || { applied: false, amountMinor: 0 };
|
||||||
|
if (flag) cur.applied = true;
|
||||||
|
cur.amountMinor += amt;
|
||||||
|
map.set(r.invoice_id, cur);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main entry point.
|
||||||
|
*
|
||||||
|
* getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' })
|
||||||
|
*
|
||||||
|
* `from` and `to` are inclusive ISO dates (YYYY-MM-DD). `currency` is
|
||||||
|
* required and must match `invoices.currency` exactly — mixing
|
||||||
|
* currencies in one report is unsound for tax filing, so the API
|
||||||
|
* forces a single-currency view.
|
||||||
|
*/
|
||||||
|
async function getTaxReport({ from, to, currency } = {}) {
|
||||||
|
if (!from || !to) {
|
||||||
|
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)');
|
||||||
|
}
|
||||||
|
if (!currency || typeof currency !== 'string') {
|
||||||
|
throw new Error('getTaxReport: `currency` is required');
|
||||||
|
}
|
||||||
|
const cur = currency.toUpperCase();
|
||||||
|
|
||||||
|
return await withRetry(async () => {
|
||||||
|
const dbRows = await db('invoices')
|
||||||
|
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||||
|
.leftJoin('events', 'invoices.event_id', 'events.id')
|
||||||
|
.whereBetween('invoices.issue_date', [from, to])
|
||||||
|
.where('invoices.currency', cur)
|
||||||
|
.whereIn('invoices.status', REPORTABLE_STATUSES)
|
||||||
|
.orderBy('invoices.invoice_number', 'asc')
|
||||||
|
.select(
|
||||||
|
'invoices.id',
|
||||||
|
'invoices.invoice_number',
|
||||||
|
'invoices.issue_date',
|
||||||
|
'invoices.currency',
|
||||||
|
'invoices.status',
|
||||||
|
'invoices.kind',
|
||||||
|
'invoices.vat_rate',
|
||||||
|
'invoices.net_amount_minor',
|
||||||
|
'invoices.vat_amount_minor',
|
||||||
|
'invoices.total_amount_minor',
|
||||||
|
'invoices.late_fee_amount_minor',
|
||||||
|
'invoices.replaces_invoice_id',
|
||||||
|
'customer_accounts.email as customer_email',
|
||||||
|
'customer_accounts.display_name as customer_display_name',
|
||||||
|
'customer_accounts.first_name as customer_first_name',
|
||||||
|
'customer_accounts.last_name as customer_last_name',
|
||||||
|
'customer_accounts.company_name as customer_company_name',
|
||||||
|
// Prefer the invoice's inline snapshot (migration 123) so
|
||||||
|
// renames on the events table don't retroactively change
|
||||||
|
// historical tax reports; fall back to events.event_name for
|
||||||
|
// legacy rows where the snapshot is still null.
|
||||||
|
db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find replacement invoice numbers for any cancelled rows so the
|
||||||
|
// UI can render "Bezug → R-XXXX" without an extra round-trip.
|
||||||
|
const cancelledIds = dbRows.filter((r) => r.status === 'cancelled').map((r) => r.id);
|
||||||
|
const replacedByMap = await loadReplacementsMap(cancelledIds);
|
||||||
|
|
||||||
|
// Skonto aggregate (migration 126). One invoice can have multiple
|
||||||
|
// payment-log rows (partial → top-up → top-up → final); we surface
|
||||||
|
// the row as "paid with Skonto" if ANY of its log rows carries the
|
||||||
|
// flag, and sum the discount across all such rows. Done as a
|
||||||
|
// separate query so the main SELECT doesn't need a GROUP BY (which
|
||||||
|
// would force every selected column into the GROUP under strict
|
||||||
|
// Postgres semantics).
|
||||||
|
const skontoByInvoiceId = await loadSkontoMap(dbRows.map((r) => r.id));
|
||||||
|
|
||||||
|
// Bucket totals by VAT rate. Use a string key so 7.7 and 7.70
|
||||||
|
// collapse to the same bucket regardless of how the DB rounds.
|
||||||
|
const byRate = new Map();
|
||||||
|
let grandTotalNet = 0;
|
||||||
|
let grandTotalVat = 0;
|
||||||
|
let grandTotal = 0;
|
||||||
|
let cancelledCount = 0;
|
||||||
|
|
||||||
|
const rows = dbRows.map((r) => {
|
||||||
|
const reported = computeReportedAmounts(r);
|
||||||
|
const isCancelled = r.status === 'cancelled';
|
||||||
|
if (isCancelled) {
|
||||||
|
cancelledCount += 1;
|
||||||
|
} else {
|
||||||
|
grandTotalNet += reported.netMinor;
|
||||||
|
grandTotalVat += reported.vatMinor;
|
||||||
|
grandTotal += reported.totalMinor;
|
||||||
|
const rateKey = String(ensureRate(r.vat_rate).toFixed(2));
|
||||||
|
const bucket = byRate.get(rateKey) || {
|
||||||
|
vatRate: ensureRate(r.vat_rate),
|
||||||
|
netMinor: 0, vatMinor: 0, totalMinor: 0,
|
||||||
|
};
|
||||||
|
bucket.netMinor += reported.netMinor;
|
||||||
|
bucket.vatMinor += reported.vatMinor;
|
||||||
|
bucket.totalMinor += reported.totalMinor;
|
||||||
|
byRate.set(rateKey, bucket);
|
||||||
|
}
|
||||||
|
const skonto = skontoByInvoiceId.get(r.id) || { applied: false, amountMinor: 0 };
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
invoiceNumber: r.invoice_number,
|
||||||
|
issueDate: r.issue_date,
|
||||||
|
currency: r.currency,
|
||||||
|
status: r.status,
|
||||||
|
// kind + isReissue drive the lineage badges in the tax-tab
|
||||||
|
// table (parity with the admin invoices list). isCancelled
|
||||||
|
// already gates the "Cancelled" badge; isReissue gates a
|
||||||
|
// "Reissue" badge on invoices created via Cancel & reissue.
|
||||||
|
kind: r.kind || 'invoice',
|
||||||
|
isCancelled,
|
||||||
|
isReissue: !isCancelled && r.replaces_invoice_id != null,
|
||||||
|
replacedByInvoiceNumber: isCancelled ? (replacedByMap.get(r.id) || null) : null,
|
||||||
|
vatRate: ensureRate(r.vat_rate),
|
||||||
|
customerLabel: buildCustomerLabel(r),
|
||||||
|
eventName: r.event_name || '',
|
||||||
|
netMinor: reported.netMinor,
|
||||||
|
vatMinor: reported.vatMinor,
|
||||||
|
totalMinor: reported.totalMinor,
|
||||||
|
// Skonto aggregate (migration 126). `skontoApplied` flags
|
||||||
|
// any row in this invoice's payment log as Skonto-applied;
|
||||||
|
// `skontoAmountMinor` is the summed discount across all such
|
||||||
|
// rows. Both surfaced so the report consumer (UI / PDF / CSV)
|
||||||
|
// can render the column without re-querying the log.
|
||||||
|
skontoApplied: skonto.applied,
|
||||||
|
skontoAmountMinor: skonto.amountMinor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalsByVatRate,
|
||||||
|
grandTotalNet,
|
||||||
|
grandTotalVat,
|
||||||
|
grandTotal,
|
||||||
|
cancelledCount,
|
||||||
|
currency: cur,
|
||||||
|
period: { from, to },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PDF + CSV renderers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the issuer block + date format that the renderers need. Mirrors
|
||||||
|
* the slice that invoiceService.buildInvoiceRenderContext builds for
|
||||||
|
* the regular invoice/quote PDFs so the letterhead looks identical.
|
||||||
|
*/
|
||||||
|
async function loadRenderContext(locale) {
|
||||||
|
const { profile } = await businessProfileService.getProfile();
|
||||||
|
let dateFormat = null;
|
||||||
|
try {
|
||||||
|
const raw = await getAppSetting('general_date_format');
|
||||||
|
if (raw && typeof raw === 'object' && raw.format) dateFormat = raw;
|
||||||
|
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
||||||
|
} catch (_) { /* fall back to renderer default */ }
|
||||||
|
|
||||||
|
const issuer = profile ? {
|
||||||
|
companyName: profile.company_name,
|
||||||
|
addressLine1: profile.address_line1,
|
||||||
|
addressLine2: profile.address_line2,
|
||||||
|
postalCode: profile.postal_code,
|
||||||
|
city: profile.city,
|
||||||
|
state: profile.state,
|
||||||
|
countryCode: profile.country_code,
|
||||||
|
countryName: profile.country_name || null,
|
||||||
|
phone: profile.phone, mobile: profile.mobile, email: profile.email, website: profile.website,
|
||||||
|
footerLine: profile.footer_line,
|
||||||
|
vatId: profile.vat_id,
|
||||||
|
logoPath: profile.logo_path,
|
||||||
|
pdfFontTtfPath: profile.pdf_font_ttf_path,
|
||||||
|
pdfFontFamily: profile.pdf_font_family || null,
|
||||||
|
showLogo: profile.pdf_show_logo == null ? true
|
||||||
|
: (profile.pdf_show_logo === true || profile.pdf_show_logo === 1 || profile.pdf_show_logo === '1'),
|
||||||
|
showCompanyName: profile.pdf_show_company_name == null ? true
|
||||||
|
: (profile.pdf_show_company_name === true || profile.pdf_show_company_name === 1 || profile.pdf_show_company_name === '1'),
|
||||||
|
logoHeight: profile.pdf_logo_height == null ? 56 : Number(profile.pdf_logo_height),
|
||||||
|
companyNameInline: profile.pdf_company_name_inline === true || profile.pdf_company_name_inline === 1 || profile.pdf_company_name_inline === '1',
|
||||||
|
// Folding marks would clutter a tax-report (no envelope window in
|
||||||
|
// play); always suppress regardless of the profile setting.
|
||||||
|
foldingMarks: 'none',
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
return { issuer, dateFormat, locale: locale || profile?.default_locale || 'de' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page layout for the tax-report table. Sized for A4 landscape (762pt
|
||||||
|
// content width). Sums to ~759 leaving ~3pt slack for the right margin.
|
||||||
|
//
|
||||||
|
// "Status" column lives at the far right so the cancelled marker
|
||||||
|
// doesn't crowd the invoice number. The invoice column itself stays
|
||||||
|
// uncluttered with just "R-2026-0001" — easier to scan for an
|
||||||
|
// auditor looking at the sequence.
|
||||||
|
const TAX_TABLE_COLS = [
|
||||||
|
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' },
|
||||||
|
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' },
|
||||||
|
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' },
|
||||||
|
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' },
|
||||||
|
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' },
|
||||||
|
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' },
|
||||||
|
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
|
||||||
|
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' },
|
||||||
|
{ key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' },
|
||||||
|
// Skonto column (migration 126) — blank for non-Skonto rows so the
|
||||||
|
// column reads quietly until it has data. Shrunk neighbouring text
|
||||||
|
// columns slightly to make space without going over the landscape
|
||||||
|
// content width.
|
||||||
|
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
|
||||||
|
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function colX(leftMargin, index) {
|
||||||
|
let x = leftMargin;
|
||||||
|
for (let i = 0; i < index; i += 1) x += TAX_TABLE_COLS[i].width;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTaxTableHeader(doc, leftMargin, y, locale, fonts) {
|
||||||
|
doc.font(fonts.bold).fontSize(8.5).fillColor('#000');
|
||||||
|
for (let i = 0; i < TAX_TABLE_COLS.length; i += 1) {
|
||||||
|
const col = TAX_TABLE_COLS[i];
|
||||||
|
doc.text(t(locale, col.labelKey), colX(leftMargin, i) + 2, y, {
|
||||||
|
width: col.width - 4, align: col.align,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const headerBottom = y + 14;
|
||||||
|
doc.moveTo(leftMargin, headerBottom)
|
||||||
|
.lineTo(leftMargin + TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0), headerBottom)
|
||||||
|
.lineWidth(0.6).strokeColor('#000').stroke();
|
||||||
|
return headerBottom + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVatRate(rate, locale) {
|
||||||
|
// 7.7 → "7.7 %" in en, "7,7 %" in de. Two decimals stripped for
|
||||||
|
// tidiness when zero (8.10 → "8.1 %").
|
||||||
|
const n = Number(rate || 0);
|
||||||
|
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
|
||||||
|
const formatted = new Intl.NumberFormat(intlLocale, {
|
||||||
|
minimumFractionDigits: 0, maximumFractionDigits: 2,
|
||||||
|
}).format(n);
|
||||||
|
return `${formatted} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowCellValues(row, idx, locale, dateFormat) {
|
||||||
|
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
|
||||||
|
return {
|
||||||
|
idx: String(idx),
|
||||||
|
date: formatDate(row.issueDate, dateFormat),
|
||||||
|
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column
|
||||||
|
customer: row.customerLabel || '',
|
||||||
|
event: row.eventName || '',
|
||||||
|
vatRate: formatVatRate(row.vatRate, locale),
|
||||||
|
net: formatMinor(row.netMinor, row.currency, intlLocale),
|
||||||
|
vat: formatMinor(row.vatMinor, row.currency, intlLocale),
|
||||||
|
total: formatMinor(row.totalMinor, row.currency, intlLocale),
|
||||||
|
skonto: row.skontoApplied
|
||||||
|
? formatMinor(row.skontoAmountMinor, row.currency, intlLocale)
|
||||||
|
: '',
|
||||||
|
status: row.isCancelled ? t(locale, 'tax_status_cancelled') : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the tax report as a PDF buffer.
|
||||||
|
*
|
||||||
|
* renderTaxReportPdf({ from, to, currency, locale }) → Promise<Buffer>
|
||||||
|
*
|
||||||
|
* Currency is required and used to scope the data (same contract as
|
||||||
|
* getTaxReport). Locale defaults to the business profile's default.
|
||||||
|
*/
|
||||||
|
async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||||
|
const report = await getTaxReport({ from, to, currency });
|
||||||
|
const renderCtx = await loadRenderContext(locale);
|
||||||
|
const useLocale = renderCtx.locale;
|
||||||
|
const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB';
|
||||||
|
|
||||||
|
const { doc, page, fonts } = pdfService.createBaseDocument({
|
||||||
|
orientation: 'landscape',
|
||||||
|
issuer: renderCtx.issuer,
|
||||||
|
info: {
|
||||||
|
Title: `${t(useLocale, 'tax_title')} ${report.period.from}–${report.period.to}`,
|
||||||
|
Author: renderCtx.issuer.companyName || 'picpeak',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (c) => chunks.push(c));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
const leftMargin = page.marginLeft;
|
||||||
|
// Issuer block: top-right, same width pattern as the existing
|
||||||
|
// invoice/quote letterhead (180pt) so the branding feels
|
||||||
|
// consistent across all admin-facing PDFs.
|
||||||
|
const issuerWidth = 180;
|
||||||
|
const issuerX = page.width - page.marginRight - issuerWidth;
|
||||||
|
const issuerY = page.marginTop + 4;
|
||||||
|
const issuerEndY = pdfService.drawIssuerBlock(
|
||||||
|
doc, renderCtx.issuer, issuerX, issuerY, issuerWidth, useLocale
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title block on the left.
|
||||||
|
doc.font(fonts.bold).fontSize(18).fillColor('#000')
|
||||||
|
.text(t(useLocale, 'tax_title'), leftMargin, page.marginTop + 4, {
|
||||||
|
width: page.contentWidth - issuerWidth - 20, align: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.font(fonts.body).fontSize(10).fillColor('#333');
|
||||||
|
const periodLine = `${t(useLocale, 'tax_period')}: ${formatDate(report.period.from, renderCtx.dateFormat)} – ${formatDate(report.period.to, renderCtx.dateFormat)}`;
|
||||||
|
doc.text(periodLine, leftMargin, page.marginTop + 30, {
|
||||||
|
width: page.contentWidth - issuerWidth - 20, align: 'left',
|
||||||
|
});
|
||||||
|
doc.text(`${t(useLocale, 'tax_currency')}: ${report.currency}`,
|
||||||
|
leftMargin, page.marginTop + 46, {
|
||||||
|
width: page.contentWidth - issuerWidth - 20, align: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table starts below whichever block (issuer or title) ends lower.
|
||||||
|
let y = Math.max(issuerEndY, page.marginTop + 70) + 14;
|
||||||
|
y = drawTaxTableHeader(doc, leftMargin, y, useLocale, fonts);
|
||||||
|
|
||||||
|
doc.fontSize(8.5);
|
||||||
|
const tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
|
||||||
|
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0);
|
||||||
|
|
||||||
|
if (report.rows.length === 0) {
|
||||||
|
doc.font(fonts.body).fontSize(10).fillColor('#555')
|
||||||
|
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
|
||||||
|
width: tableWidth, align: 'center',
|
||||||
|
});
|
||||||
|
y += 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row height is now DYNAMIC — computed per row as the max
|
||||||
|
// rendered height across every cell at its column width. This
|
||||||
|
// means a cell that wraps to two lines (long customer label,
|
||||||
|
// multi-line event name, "Storniert" tag in a narrow status
|
||||||
|
// column) makes the whole row taller instead of overlapping
|
||||||
|
// the row below. The minimum keeps tight rows readable.
|
||||||
|
const ROW_MIN_HEIGHT = 14;
|
||||||
|
const ROW_VERTICAL_PADDING = 4; // space between text and the separator line
|
||||||
|
const safeStr = (v) => (v == null ? '' : String(v));
|
||||||
|
|
||||||
|
// Measure how tall a value would render in the given column.
|
||||||
|
// Numeric / aligned cells use `lineBreak: false` so they never
|
||||||
|
// wrap (they're either ints or money strings whose width we
|
||||||
|
// budget for) — only text cells (customer, event, invoice,
|
||||||
|
// status) opt into natural wrapping.
|
||||||
|
const isWrappable = (col) => ['invoice', 'customer', 'event', 'status'].includes(col.key);
|
||||||
|
const measureCellHeight = (value, col) => {
|
||||||
|
const s = safeStr(value);
|
||||||
|
if (!s) return 0;
|
||||||
|
const opts = isWrappable(col)
|
||||||
|
? { width: col.width - 4, align: col.align }
|
||||||
|
: { width: col.width - 4, align: col.align, lineBreak: false };
|
||||||
|
// `doc.heightOfString` reads the current font + fontSize, so
|
||||||
|
// we set the body font + 8.5pt before each row's measurement
|
||||||
|
// pass and the values stay consistent with the actual draw.
|
||||||
|
return doc.heightOfString(s, opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < report.rows.length; i += 1) {
|
||||||
|
const row = report.rows[i];
|
||||||
|
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat);
|
||||||
|
|
||||||
|
// Set the font BEFORE measuring so heightOfString reads the
|
||||||
|
// exact rendering state we'll use for doc.text below.
|
||||||
|
doc.font(fonts.body).fontSize(8.5);
|
||||||
|
|
||||||
|
let textHeight = ROW_MIN_HEIGHT - ROW_VERTICAL_PADDING;
|
||||||
|
for (const col of TAX_TABLE_COLS) {
|
||||||
|
const h = measureCellHeight(cells[col.key], col);
|
||||||
|
if (h > textHeight) textHeight = h;
|
||||||
|
}
|
||||||
|
const rowH = Math.ceil(textHeight) + ROW_VERTICAL_PADDING;
|
||||||
|
|
||||||
|
// Page break check uses the actual row height we're about to
|
||||||
|
// draw, not the old hard-coded constant — long rows can't
|
||||||
|
// sneak past the bottom margin. Pass margins explicitly so the
|
||||||
|
// new page inherits the same 40pt frame as page 1 — without
|
||||||
|
// this, PDFKit's addPage falls back to its 72pt default and
|
||||||
|
// the footer-Y math (`page.height - page.marginBottom - 12`)
|
||||||
|
// ends up positioned for a margin the page doesn't actually
|
||||||
|
// have, which is what made the page-number footer drift onto
|
||||||
|
// the wrong row of subsequent pages.
|
||||||
|
if (y + rowH > tableBottomLimit) {
|
||||||
|
doc.addPage({
|
||||||
|
size: 'A4', layout: 'landscape',
|
||||||
|
margins: {
|
||||||
|
top: page.marginTop, bottom: page.marginBottom,
|
||||||
|
left: page.marginLeft, right: page.marginRight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
y = page.marginTop;
|
||||||
|
y = drawTaxTableHeader(doc, leftMargin, y, useLocale, fonts);
|
||||||
|
doc.font(fonts.body).fontSize(8.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.fillColor(row.isCancelled ? '#888' : '#000');
|
||||||
|
|
||||||
|
for (let c = 0; c < TAX_TABLE_COLS.length; c += 1) {
|
||||||
|
const col = TAX_TABLE_COLS[c];
|
||||||
|
const opts = isWrappable(col)
|
||||||
|
? { width: col.width - 4, align: col.align }
|
||||||
|
: { width: col.width - 4, align: col.align, lineBreak: false };
|
||||||
|
doc.text(safeStr(cells[col.key]), colX(leftMargin, c) + 2, y, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light separator under each row, drawn at the dynamic
|
||||||
|
// bottom edge — not at a fixed offset.
|
||||||
|
doc.moveTo(leftMargin, y + rowH - 1)
|
||||||
|
.lineTo(leftMargin + tableWidth, y + rowH - 1)
|
||||||
|
.lineWidth(0.3).strokeColor('#e0e0e0').stroke();
|
||||||
|
y += rowH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals block. Lives in the right half of the page so it
|
||||||
|
// doesn't fight with the cancelled footnote on the left.
|
||||||
|
//
|
||||||
|
// Estimate the totals block height up-front: header (16) +
|
||||||
|
// 13pt per VAT bucket row + divider (8) + three grand-total
|
||||||
|
// rows (39) + a 12pt cushion for the footer below. If that
|
||||||
|
// doesn't fit on the current page, force a new page now —
|
||||||
|
// otherwise PDFKit auto-paginates mid-totals, creating phantom
|
||||||
|
// pages whose footer ends up at unexpected Y positions on the
|
||||||
|
// subsequent bufferedPageRange loop.
|
||||||
|
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12;
|
||||||
|
const footerReserve = 24; // 12 above + 12 of page-number text room
|
||||||
|
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
|
||||||
|
doc.addPage({
|
||||||
|
size: 'A4', layout: 'landscape',
|
||||||
|
margins: {
|
||||||
|
top: page.marginTop, bottom: page.marginBottom,
|
||||||
|
left: page.marginLeft, right: page.marginRight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
y = page.marginTop;
|
||||||
|
}
|
||||||
|
const totalsTop = y + 12;
|
||||||
|
const totalsBoxWidth = 360;
|
||||||
|
const totalsX = page.width - page.marginRight - totalsBoxWidth;
|
||||||
|
|
||||||
|
doc.font(fonts.bold).fontSize(10).fillColor('#000')
|
||||||
|
.text(t(useLocale, 'tax_totals_by_rate'), totalsX, totalsTop, {
|
||||||
|
width: totalsBoxWidth, align: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
let ty = totalsTop + 16;
|
||||||
|
doc.font(fonts.body).fontSize(9);
|
||||||
|
for (const bucket of report.totalsByVatRate) {
|
||||||
|
const labelLeft = `${formatVatRate(bucket.vatRate, useLocale)}`;
|
||||||
|
doc.text(labelLeft, totalsX, ty, { width: 80, align: 'left' });
|
||||||
|
doc.text(formatMinor(bucket.netMinor, report.currency, intlLocale),
|
||||||
|
totalsX + 80, ty, { width: 90, align: 'right' });
|
||||||
|
doc.text(formatMinor(bucket.vatMinor, report.currency, intlLocale),
|
||||||
|
totalsX + 175, ty, { width: 90, align: 'right' });
|
||||||
|
doc.text(formatMinor(bucket.totalMinor, report.currency, intlLocale),
|
||||||
|
totalsX + 270, ty, { width: 90, align: 'right' });
|
||||||
|
ty += 13;
|
||||||
|
}
|
||||||
|
// Divider above grand totals.
|
||||||
|
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
|
||||||
|
.lineWidth(0.6).strokeColor('#000').stroke();
|
||||||
|
ty += 6;
|
||||||
|
doc.font(fonts.bold);
|
||||||
|
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' });
|
||||||
|
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale),
|
||||||
|
totalsX + 175, ty, { width: 90, align: 'right' });
|
||||||
|
ty += 13;
|
||||||
|
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' });
|
||||||
|
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale),
|
||||||
|
totalsX + 175, ty, { width: 90, align: 'right' });
|
||||||
|
ty += 13;
|
||||||
|
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' });
|
||||||
|
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
|
||||||
|
totalsX + 270, ty, { width: 90, align: 'right' });
|
||||||
|
|
||||||
|
// Cancelled footnote (bottom-left). Only when there are any.
|
||||||
|
if (report.cancelledCount > 0) {
|
||||||
|
doc.font(fonts.body).fontSize(8).fillColor('#555')
|
||||||
|
.text(
|
||||||
|
t(useLocale, 'tax_cancelled_footnote', { count: report.cancelledCount }),
|
||||||
|
leftMargin, totalsTop,
|
||||||
|
{ width: page.contentWidth - totalsBoxWidth - 20, align: 'left' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page x of N footer (bottom-right). Done after all body
|
||||||
|
// rendering via PDFKit's bufferPages so we know the final count
|
||||||
|
// before stamping. Resets fill colour + font so the stamp looks
|
||||||
|
// identical on every page regardless of where rendering ended.
|
||||||
|
const range = doc.bufferedPageRange();
|
||||||
|
for (let pageIdx = 0; pageIdx < range.count; pageIdx += 1) {
|
||||||
|
doc.switchToPage(range.start + pageIdx);
|
||||||
|
const pageLabel = t(useLocale, 'page_of', {
|
||||||
|
current: pageIdx + 1, total: range.count,
|
||||||
|
});
|
||||||
|
// Position the page label just ABOVE the bottom margin —
|
||||||
|
// keeping the baseline inside the content area prevents
|
||||||
|
// PDFKit's layout engine from auto-paginating when the
|
||||||
|
// 8pt-tall text wouldn't fit between the requested y and
|
||||||
|
// the bottom of the page. The previous +6 offset pushed the
|
||||||
|
// y into the margin, which made PDFKit add a fresh blank
|
||||||
|
// page for every label, doubling the page count. Mirror the
|
||||||
|
// safe `- 12` offset used by the invoice/quote renderer in
|
||||||
|
// pdfService.renderDocument().
|
||||||
|
doc.font(fonts.body).fontSize(8).fillColor('#888')
|
||||||
|
.text(pageLabel,
|
||||||
|
page.width - page.marginRight - 160,
|
||||||
|
page.height - page.marginBottom - 12,
|
||||||
|
{ width: 160, align: 'right', lineBreak: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the tax report as a CSV string. Header row in the admin's
|
||||||
|
* locale; numbers use a dot decimal separator (universal for CSV
|
||||||
|
* import into Excel/Numbers/accounting software) so we don't have to
|
||||||
|
* thread locale-specific formatting into the export.
|
||||||
|
*
|
||||||
|
* renderTaxReportCsv({ from, to, currency, locale })
|
||||||
|
* → Promise<{ content, filename, contentType }>
|
||||||
|
*/
|
||||||
|
async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
|
||||||
|
const report = await getTaxReport({ from, to, currency });
|
||||||
|
const useLocale = locale || 'en';
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
t(useLocale, 'tax_col_no'),
|
||||||
|
t(useLocale, 'tax_col_date'),
|
||||||
|
t(useLocale, 'tax_col_invoice'),
|
||||||
|
t(useLocale, 'tax_col_customer'),
|
||||||
|
t(useLocale, 'tax_col_event'),
|
||||||
|
t(useLocale, 'tax_col_vat_rate'),
|
||||||
|
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
|
||||||
|
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
|
||||||
|
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
|
||||||
|
t(useLocale, 'tax_status_cancelled'),
|
||||||
|
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
|
||||||
|
// amount in major units; admin's accountant reconciles the line.
|
||||||
|
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const escape = (cell) => {
|
||||||
|
const s = cell === null || cell === undefined ? '' : String(cell);
|
||||||
|
// RFC 4180: wrap in quotes when the value contains comma, quote,
|
||||||
|
// or newline. We always wrap, simpler + bulletproof for Excel.
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2);
|
||||||
|
|
||||||
|
const lines = [headers.map(escape).join(',')];
|
||||||
|
report.rows.forEach((row, i) => {
|
||||||
|
lines.push([
|
||||||
|
i + 1,
|
||||||
|
row.issueDate,
|
||||||
|
row.invoiceNumber,
|
||||||
|
row.customerLabel,
|
||||||
|
row.eventName,
|
||||||
|
Number(row.vatRate).toFixed(2),
|
||||||
|
minorToDotDecimal(row.netMinor),
|
||||||
|
minorToDotDecimal(row.vatMinor),
|
||||||
|
minorToDotDecimal(row.totalMinor),
|
||||||
|
row.isCancelled ? '1' : '0',
|
||||||
|
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
|
||||||
|
].map(escape).join(','));
|
||||||
|
});
|
||||||
|
// Trailing totals row: blank cells + grand totals at the end so
|
||||||
|
// the column alignment matches the data rows when opened in Excel.
|
||||||
|
lines.push('');
|
||||||
|
lines.push([
|
||||||
|
'', '', '',
|
||||||
|
t(useLocale, 'tax_grand_total_gross'),
|
||||||
|
'', '',
|
||||||
|
minorToDotDecimal(report.grandTotalNet),
|
||||||
|
minorToDotDecimal(report.grandTotalVat),
|
||||||
|
minorToDotDecimal(report.grandTotal),
|
||||||
|
'', '',
|
||||||
|
].map(escape).join(','));
|
||||||
|
|
||||||
|
const content = lines.join('\r\n') + '\r\n';
|
||||||
|
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
||||||
|
return { content, filename, contentType: 'text/csv; charset=utf-8' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTaxReport,
|
||||||
|
renderTaxReportPdf,
|
||||||
|
renderTaxReportCsv,
|
||||||
|
// Exposed for unit tests.
|
||||||
|
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate },
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Small helper to read app_settings rows.
|
||||||
|
*
|
||||||
|
* The picpeak codebase has TWO settings services:
|
||||||
|
* - `src/services/settingsService.js` queries a `settings` table that
|
||||||
|
* doesn't actually exist on most deployments (legacy SQLite-era
|
||||||
|
* name). Calling getSetting() from there raises
|
||||||
|
* "relation \"settings\" does not exist" on Postgres.
|
||||||
|
* - The canonical store is `app_settings`, accessed inline by every
|
||||||
|
* other service (shareLinkService, customerAccountsService,
|
||||||
|
* authSecurity, dateFormatter, …).
|
||||||
|
*
|
||||||
|
* The CRM services use this helper instead of settingsService so the
|
||||||
|
* crm_* keys seeded by migration 102 are actually readable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a single app_settings row by key. Returns the parsed value or
|
||||||
|
* `defaultValue` when the key doesn't exist.
|
||||||
|
*
|
||||||
|
* `setting_value` is always JSON-stringified at write time
|
||||||
|
* (see migration 102 + the /admin/settings/general route), so we
|
||||||
|
* JSON.parse on the way out. Falls back to the raw string on
|
||||||
|
* malformed JSON so legacy text values still work.
|
||||||
|
*/
|
||||||
|
async function getAppSetting(key, defaultValue = null) {
|
||||||
|
const row = await db('app_settings').where({ setting_key: key }).first();
|
||||||
|
if (!row || row.setting_value == null) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.setting_value);
|
||||||
|
} catch (_) {
|
||||||
|
return row.setting_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAppSetting };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* clientIp — resolve the originating client IP for audit-trail
|
||||||
|
* recording (contract signing, quote responses, payment-check
|
||||||
|
* actions, etc.).
|
||||||
|
*
|
||||||
|
* **Why this helper exists:** the public-facing routes used to read
|
||||||
|
* `req.headers['x-forwarded-for']` directly and take the first
|
||||||
|
* comma-segment as the source IP. That bypasses Express's `trust
|
||||||
|
* proxy` safety net entirely — any direct (non-proxied) POST to the
|
||||||
|
* signing endpoint can spoof the audit IP by setting the header,
|
||||||
|
* which defeats the legal-evidence promise of the contract feature.
|
||||||
|
*
|
||||||
|
* **Correct path:** trust ONLY `req.ip`, and rely on
|
||||||
|
* `app.set('trust proxy', ...)` in `server.js` to populate it
|
||||||
|
* correctly. Express's trust-proxy machinery is the only thing that
|
||||||
|
* knows which upstream hops are trustworthy. The default in
|
||||||
|
* `server.js` (`'loopback, linklocal, uniquelocal'`) is correct for
|
||||||
|
* picpeak's standard deployment (nginx in front, Docker network);
|
||||||
|
* operators with unusual topologies override via `TRUST_PROXY` env.
|
||||||
|
*
|
||||||
|
* **Returns:** the resolved IPv4/IPv6 string, or `null` when Express
|
||||||
|
* couldn't determine one (very rare — happens with abusive raw
|
||||||
|
* sockets / malformed connections).
|
||||||
|
*
|
||||||
|
* **Storage:** call sites still gate persistence on a separate
|
||||||
|
* privacy setting (e.g. `crm_contracts_store_ip`). This helper only
|
||||||
|
* concerns itself with *which* IP to record, not *whether* to
|
||||||
|
* record one.
|
||||||
|
*/
|
||||||
|
function clientIpForAudit(req) {
|
||||||
|
if (!req) return null;
|
||||||
|
return req.ip || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { clientIpForAudit };
|
||||||
@@ -97,6 +97,27 @@ async function formatDate(date, language = 'en') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync DD.MM.YYYY formatter used by quote / invoice / contract render
|
||||||
|
* contexts. Unlike `formatDate` above, this never consults app_settings
|
||||||
|
* — it's intended for fixed-format use inside templates already rendered
|
||||||
|
* for a specific document type. Three services used to ship a local
|
||||||
|
* copy each; this is the single source.
|
||||||
|
*
|
||||||
|
* - falsy input → empty string (template's {{#if ...}} block hides)
|
||||||
|
* - invalid date → the original value coerced to String (defensive
|
||||||
|
* passthrough; matches the prior behaviour of the three local copies)
|
||||||
|
*/
|
||||||
|
function formatShortDate(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatDate
|
formatDate,
|
||||||
|
formatShortDate,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* documentSequences — atomic gap-free sequence generator for CRM
|
||||||
|
* document numbers (invoices, quotes, contracts, future doc kinds).
|
||||||
|
*
|
||||||
|
* **Contract**: `claimNextSequence(kind, year, [trx])` returns the
|
||||||
|
* next integer in the (kind, year) series. Atomic against concurrent
|
||||||
|
* callers: two simultaneous claims for the same row return strictly
|
||||||
|
* increasing values, no collisions, no gaps.
|
||||||
|
*
|
||||||
|
* **How the atomicity works**
|
||||||
|
*
|
||||||
|
* Postgres: a single `UPDATE … SET current_value = current_value + 1
|
||||||
|
* WHERE kind = ? AND year = ? RETURNING current_value` holds a row
|
||||||
|
* lock for the duration of the statement; concurrent callers
|
||||||
|
* serialize on the lock.
|
||||||
|
*
|
||||||
|
* SQLite: knex does not expose `BEGIN IMMEDIATE` declaratively, but
|
||||||
|
* SQLite's default journal mode (or WAL) gives us per-row serialization
|
||||||
|
* via the transaction. We wrap the UPDATE + re-SELECT in a transaction
|
||||||
|
* which acquires the write lock; concurrent transactions queue.
|
||||||
|
*
|
||||||
|
* **First-claim path** (no row yet for the (kind, year))
|
||||||
|
*
|
||||||
|
* Migration 132 seeded rows for every existing year via MAX(...)
|
||||||
|
* backfill. New years need an INSERT on first use. We do an
|
||||||
|
* INSERT-OR-IGNORE then UPDATE … RETURNING. Both steps are inside
|
||||||
|
* the same transaction so the year row is guaranteed to exist when
|
||||||
|
* the UPDATE fires.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { AppError } = require('./errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the next sequence value for (kind, year). Returns the new
|
||||||
|
* integer. Throws AppError on DB failure; caller composes the
|
||||||
|
* formatted document number from this integer via formatNumberInTemplate.
|
||||||
|
*
|
||||||
|
* @param {string} kind 'invoice' | 'quote' | 'contract' | ...
|
||||||
|
* @param {number} year 4-digit year
|
||||||
|
* @param {object} [trx] optional knex transaction. When supplied the
|
||||||
|
* claim joins the caller's transaction so the
|
||||||
|
* sequence increment and the row INSERT can
|
||||||
|
* commit-or-roll-back together. Otherwise we
|
||||||
|
* run our own micro-transaction.
|
||||||
|
*/
|
||||||
|
async function claimNextSequence(kind, year, trx) {
|
||||||
|
if (!kind || typeof kind !== 'string') {
|
||||||
|
throw new AppError('claimNextSequence: kind required', 500);
|
||||||
|
}
|
||||||
|
const yr = parseInt(year, 10);
|
||||||
|
if (!Number.isFinite(yr)) {
|
||||||
|
throw new AppError('claimNextSequence: invalid year', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exec = async (q) => {
|
||||||
|
// Step 1: ensure the (kind, year) row exists. INSERT...ON CONFLICT
|
||||||
|
// DO NOTHING is the Postgres-native form; SQLite supports the same
|
||||||
|
// syntax (3.24+). knex's `onConflict('...').ignore()` paves over
|
||||||
|
// the differences.
|
||||||
|
await q('document_sequences')
|
||||||
|
.insert({
|
||||||
|
kind, year: yr, current_value: 0,
|
||||||
|
created_at: new Date(), updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.onConflict(['kind', 'year']).ignore();
|
||||||
|
|
||||||
|
// Step 2: atomic claim. We do UPDATE … (no RETURNING because
|
||||||
|
// knex's returning() is uneven across drivers) then re-SELECT.
|
||||||
|
// The transaction wrapper (or the caller's trx) keeps the two
|
||||||
|
// statements on the same row lock, so concurrent claimers
|
||||||
|
// serialize.
|
||||||
|
await q('document_sequences')
|
||||||
|
.where({ kind, year: yr })
|
||||||
|
.increment('current_value', 1)
|
||||||
|
.update({ updated_at: new Date() });
|
||||||
|
const row = await q('document_sequences')
|
||||||
|
.where({ kind, year: yr })
|
||||||
|
.select('current_value')
|
||||||
|
.first();
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError(`claimNextSequence: row vanished for ${kind}/${yr}`, 500);
|
||||||
|
}
|
||||||
|
return row.current_value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trx) {
|
||||||
|
return await exec(trx);
|
||||||
|
}
|
||||||
|
return await db.transaction(async (innerTrx) => exec(innerTrx));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { claimNextSequence };
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* IBAN validation per ISO 13616.
|
||||||
|
*
|
||||||
|
* Three checks:
|
||||||
|
* 1. Format — 2 uppercase letters (country) + 2 digits (check) +
|
||||||
|
* alphanumeric BBAN.
|
||||||
|
* 2. Length — each country fixes a total IBAN length. We accept any
|
||||||
|
* country whose ISO code we recognise; unknown country codes
|
||||||
|
* fall back to a generic 15–34 char range (ISO 13616 caps every
|
||||||
|
* IBAN at 34 chars).
|
||||||
|
* 3. Mod-97 checksum — rearrange the IBAN so the first four chars
|
||||||
|
* land at the end, expand letters to digits (A=10..Z=35), the
|
||||||
|
* result modulo 97 MUST equal 1. Catches single-digit typos and
|
||||||
|
* digit transpositions with high probability.
|
||||||
|
*
|
||||||
|
* The validator is pure (no IO, no DB, no network) and returns a
|
||||||
|
* structured result so callers can surface a precise reason to the
|
||||||
|
* user.
|
||||||
|
*
|
||||||
|
* What this does NOT do:
|
||||||
|
* - Confirm the bank itself exists (would require an external
|
||||||
|
* directory or a bank-routing API — out of scope here).
|
||||||
|
* - Validate the BBAN's internal structure beyond length + charset
|
||||||
|
* (country-specific BBAN rules are not enforced).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { valid, normalized, reason } = validateIban(' ch 93 0076 2011 6238 5295 7 ');
|
||||||
|
* if (!valid) throw new Error(reason);
|
||||||
|
* // normalized === 'CH9300762011623852957'
|
||||||
|
*/
|
||||||
|
|
||||||
|
// IBAN length per ISO country code (ISO 13616, public registry).
|
||||||
|
// Anything not in this table falls through to the 15–34 range check.
|
||||||
|
// Source: SWIFT IBAN Registry. Update when new countries are added.
|
||||||
|
const IBAN_LENGTHS = {
|
||||||
|
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28,
|
||||||
|
BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28,
|
||||||
|
CH: 21, CR: 22, CY: 28, CZ: 24,
|
||||||
|
DE: 22, DK: 18, DO: 28,
|
||||||
|
EE: 20, EG: 29, ES: 24,
|
||||||
|
FI: 18, FO: 18, FR: 27,
|
||||||
|
GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28,
|
||||||
|
HR: 21, HU: 28,
|
||||||
|
IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27,
|
||||||
|
JO: 30,
|
||||||
|
KW: 30, KZ: 20,
|
||||||
|
LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25,
|
||||||
|
MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30,
|
||||||
|
NL: 18, NO: 15,
|
||||||
|
PK: 24, PL: 28, PS: 29, PT: 25,
|
||||||
|
QA: 29,
|
||||||
|
RO: 24, RS: 22,
|
||||||
|
SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28,
|
||||||
|
TL: 23, TN: 24, TR: 26,
|
||||||
|
UA: 29,
|
||||||
|
VA: 22, VG: 24,
|
||||||
|
XK: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rearrange + numerify the IBAN per ISO 13616 then take mod 97.
|
||||||
|
* The whole-string-as-BigInt approach is acceptable here: max IBAN
|
||||||
|
* length is 34 chars → numerified length is at most ~68 digits.
|
||||||
|
* Native BigInt is plenty fast for one-off validation.
|
||||||
|
*/
|
||||||
|
function mod97(iban) {
|
||||||
|
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
||||||
|
let expanded = '';
|
||||||
|
for (const ch of rearranged) {
|
||||||
|
if (ch >= '0' && ch <= '9') {
|
||||||
|
expanded += ch;
|
||||||
|
} else if (ch >= 'A' && ch <= 'Z') {
|
||||||
|
// A=10, B=11, ..., Z=35
|
||||||
|
expanded += String(ch.charCodeAt(0) - 55);
|
||||||
|
} else {
|
||||||
|
return -1; // invalid char — caller treats as failed checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Standard chunked mod-97 to avoid BigInt allocation cost.
|
||||||
|
let remainder = 0;
|
||||||
|
for (const digit of expanded) {
|
||||||
|
remainder = (remainder * 10 + Number(digit)) % 97;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise + validate an IBAN string.
|
||||||
|
*
|
||||||
|
* @param {unknown} input Raw user-typed value. Spaces and lowercase
|
||||||
|
* letters are tolerated and stripped/uppercased
|
||||||
|
* before checking.
|
||||||
|
* @returns {{
|
||||||
|
* valid: boolean,
|
||||||
|
* normalized: string, // empty when input wasn't a string
|
||||||
|
* reason?: 'EMPTY' // nothing useful supplied
|
||||||
|
* | 'FORMAT' // failed the structural regex
|
||||||
|
* | 'LENGTH' // wrong length for the country
|
||||||
|
* | 'CHECKSUM' // mod-97 didn't equal 1
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function validateIban(input) {
|
||||||
|
if (input == null) return { valid: false, normalized: '', reason: 'EMPTY' };
|
||||||
|
const raw = String(input).replace(/\s+/g, '').toUpperCase();
|
||||||
|
if (!raw) return { valid: false, normalized: '', reason: 'EMPTY' };
|
||||||
|
|
||||||
|
// ISO 13616: starts with 2 letters (country) + 2 digits (check) +
|
||||||
|
// 11..30 chars of alphanumeric BBAN. Total length 15..34.
|
||||||
|
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(raw)) {
|
||||||
|
return { valid: false, normalized: raw, reason: 'FORMAT' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const country = raw.slice(0, 2);
|
||||||
|
const expectedLen = IBAN_LENGTHS[country];
|
||||||
|
if (expectedLen != null) {
|
||||||
|
if (raw.length !== expectedLen) {
|
||||||
|
return { valid: false, normalized: raw, reason: 'LENGTH' };
|
||||||
|
}
|
||||||
|
} else if (raw.length < 15 || raw.length > 34) {
|
||||||
|
return { valid: false, normalized: raw, reason: 'LENGTH' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mod97(raw) !== 1) {
|
||||||
|
return { valid: false, normalized: raw, reason: 'CHECKSUM' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, normalized: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateIban,
|
||||||
|
// Exposed for unit tests.
|
||||||
|
_internal: { mod97, IBAN_LENGTHS },
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Shared numeric coercion helpers used across the CRM services.
|
||||||
|
*
|
||||||
|
* Previously: 4 copies of `ensureInt` + 2 copies of `ensureNumber` lived
|
||||||
|
* across quoteService, invoiceService, contractService, and
|
||||||
|
* taxReportService. Each copy was identical apart from `Number.isFinite`
|
||||||
|
* vs `!Number.isNaN` — converging on the same answer in practice
|
||||||
|
* because `parseInt`/`Number` never produce `Infinity` from string input.
|
||||||
|
*
|
||||||
|
* One canonical pair lives here so future numeric coercion concerns
|
||||||
|
* (e.g. BigInt safety, locale-aware decimals) are addressed in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a value to a non-NaN integer, defaulting to 0 on garbage.
|
||||||
|
* Matches the legacy `ensureInt` semantics across all four services.
|
||||||
|
*/
|
||||||
|
function ensureInt(value) {
|
||||||
|
const n = parseInt(value, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a value to a finite Number, defaulting to `fallback` (0) on
|
||||||
|
* null/undefined/empty string/NaN. Matches the legacy `ensureNumber`
|
||||||
|
* shape used by quote + invoice line-item math.
|
||||||
|
*/
|
||||||
|
function ensureNumber(value, fallback = 0) {
|
||||||
|
if (value === null || value === undefined || value === '') return fallback;
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureInt, ensureNumber };
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Build a consistent filesystem-safe filename for quote / invoice
|
||||||
|
* PDFs. Format:
|
||||||
|
*
|
||||||
|
* <docNumber>_<customerLabel>.pdf
|
||||||
|
*
|
||||||
|
* - docNumber: the invoice/quote number as printed
|
||||||
|
* - customerLabel: customer.company_name || full person name ||
|
||||||
|
* display_name || email-local-part || 'customer'
|
||||||
|
*
|
||||||
|
* Both segments are sanitised: spaces → '-', non-ASCII letters
|
||||||
|
* preserved, slashes/colons/quotes stripped, length capped so the
|
||||||
|
* combined filename stays under the typical 255-byte filesystem
|
||||||
|
* limit (we cap each side at 80 chars, which is generous for both
|
||||||
|
* pieces).
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - Content-Disposition headers on every admin + customer PDF
|
||||||
|
* endpoint
|
||||||
|
* - The PDF's internal `Title` metadata (Chrome's PDF viewer
|
||||||
|
* uses this as the default name when saving from a blob URL,
|
||||||
|
* where Content-Disposition can't reach)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sanitiseSegment(input, maxLen = 80) {
|
||||||
|
if (!input) return '';
|
||||||
|
let s = String(input).trim();
|
||||||
|
// Replace OS-hostile characters with '-'.
|
||||||
|
s = s.replace(/[/\\:*?"<>|]+/g, '-');
|
||||||
|
// Collapse whitespace runs into a single '-'.
|
||||||
|
s = s.replace(/\s+/g, '-');
|
||||||
|
// Collapse repeat dashes.
|
||||||
|
s = s.replace(/-+/g, '-');
|
||||||
|
// Trim leading/trailing dashes + dots.
|
||||||
|
s = s.replace(/^[-.]+|[-.]+$/g, '');
|
||||||
|
if (s.length > maxLen) s = s.slice(0, maxLen);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a label representing the customer for the filename. Tries
|
||||||
|
* company name first (most useful for filing), then full person
|
||||||
|
* name, then display name, then the email's local part, finally
|
||||||
|
* 'customer' as a generic fallback.
|
||||||
|
*
|
||||||
|
* @param {object} customer customer_accounts row (snake_case)
|
||||||
|
* @returns {string} sanitised label segment
|
||||||
|
*/
|
||||||
|
function customerLabel(customer) {
|
||||||
|
if (!customer) return 'customer';
|
||||||
|
const company = (customer.company_name || '').trim();
|
||||||
|
if (company) return sanitiseSegment(company);
|
||||||
|
const fullName = [customer.first_name, customer.last_name]
|
||||||
|
.map((v) => (v || '').trim()).filter(Boolean).join(' ');
|
||||||
|
if (fullName) return sanitiseSegment(fullName);
|
||||||
|
const display = (customer.display_name || '').trim();
|
||||||
|
if (display) return sanitiseSegment(display);
|
||||||
|
const email = (customer.email || '').trim();
|
||||||
|
if (email) return sanitiseSegment(email.split('@')[0]);
|
||||||
|
return 'customer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the final filename. Always ends with `.pdf`. When the
|
||||||
|
* document number is missing (e.g. preview of an unsaved row),
|
||||||
|
* substitutes a sensible fallback.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* - docNumber: 'R-2026-0001' / 'Q-2026-0042' / null for previews
|
||||||
|
* - customer: customer_accounts row
|
||||||
|
* - fallback: prefix when docNumber is null ('invoice-preview' etc.)
|
||||||
|
*/
|
||||||
|
function buildPdfFilename({ docNumber, customer, fallback = 'document' }) {
|
||||||
|
const numberSeg = sanitiseSegment(docNumber) || sanitiseSegment(fallback) || 'document';
|
||||||
|
const custSeg = customerLabel(customer);
|
||||||
|
return `${numberSeg}_${custSeg}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildPdfFilename, sanitiseSegment, customerLabel };
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* publicTokenGuards — shared validators for the public token tables
|
||||||
|
* (`contract_action_tokens`, `quote_action_tokens`). Centralises the
|
||||||
|
* checks that every public-facing route MUST run before doing work,
|
||||||
|
* so future routes can't accidentally skip a guard.
|
||||||
|
*
|
||||||
|
* What this enforces:
|
||||||
|
* 1. **Existence** — 404 when the token doesn't match a row.
|
||||||
|
* 2. **Expiry** — 410 when `expires_at` is in the past
|
||||||
|
* OR is NULL (defensive: NULL = expired,
|
||||||
|
* not "valid forever" — historical bug).
|
||||||
|
* 3. **One-shot semantics** — when `requireUnused: true`, 409 if
|
||||||
|
* `used_at` is already set. Prevents
|
||||||
|
* replay of leaked tokens on the upload
|
||||||
|
* path. The sign path historically allowed
|
||||||
|
* re-signing for in-browser flows; opt in
|
||||||
|
* per call site.
|
||||||
|
* 4. **Attempt throttling** — non-existent tokens increment a per-IP
|
||||||
|
* counter; the IP is locked out for 15 min
|
||||||
|
* after 20 invalid attempts. Mitigates the
|
||||||
|
* token-prefix brute force route that
|
||||||
|
* standard rate-limiters don't catch
|
||||||
|
* (large token space, low miss rate per
|
||||||
|
* IP, but distributed crawlers add up).
|
||||||
|
*
|
||||||
|
* Returns the validated token row on success. Sends the appropriate
|
||||||
|
* HTTP response and returns `null` on failure — the caller must check
|
||||||
|
* for null and `return` immediately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { clientIpForAudit } = require('./clientIp');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
// In-memory bad-attempt counter. Per-process; cleared on restart.
|
||||||
|
// Keyed by IP. Each entry: { count, firstAt }. We could persist this
|
||||||
|
// in app_settings or a dedicated table, but in-memory is simpler and
|
||||||
|
// good enough for the threat (distributed brute force is the only
|
||||||
|
// case where IP locking helps anyway, and that needs more than one
|
||||||
|
// IP to be effective).
|
||||||
|
const BAD_ATTEMPT_LIMIT = 20;
|
||||||
|
const BAD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const badAttempts = new Map();
|
||||||
|
|
||||||
|
function recordBadAttempt(ip) {
|
||||||
|
if (!ip) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = badAttempts.get(ip);
|
||||||
|
if (!entry || (now - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
|
||||||
|
badAttempts.set(ip, { count: 1, firstAt: now });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpLocked(ip) {
|
||||||
|
if (!ip) return false;
|
||||||
|
const entry = badAttempts.get(ip);
|
||||||
|
if (!entry) return false;
|
||||||
|
if ((Date.now() - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
|
||||||
|
badAttempts.delete(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return entry.count >= BAD_ATTEMPT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a public action token. Returns the token row on success,
|
||||||
|
* sends a response + returns null on failure.
|
||||||
|
*
|
||||||
|
* @param {object} req Express request (for IP)
|
||||||
|
* @param {object} res Express response (to send errors)
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.tableName 'contract_action_tokens' | 'quote_action_tokens'
|
||||||
|
* @param {string} opts.token 64-hex token string
|
||||||
|
* @param {boolean} [opts.requireUnused] refuse when used_at is set (default false)
|
||||||
|
*/
|
||||||
|
async function loadActionToken(req, res, opts) {
|
||||||
|
const { tableName, token, requireUnused = false } = opts;
|
||||||
|
const ip = clientIpForAudit(req);
|
||||||
|
|
||||||
|
if (isIpLocked(ip)) {
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many invalid token attempts. Try again in 15 minutes.',
|
||||||
|
code: 'TOKEN_LOOKUP_LOCKED',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await db(tableName).where({ token }).first();
|
||||||
|
if (!row) {
|
||||||
|
recordBadAttempt(ip);
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive: NULL expires_at counts as expired. Historical bug —
|
||||||
|
// old seed rows could land without an expiry value, granting
|
||||||
|
// permanent unauthenticated access. We refuse rather than guess.
|
||||||
|
if (!row.expires_at) {
|
||||||
|
logger.warn('publicTokenGuards: token has NULL expires_at — refusing', {
|
||||||
|
tableName, tokenPrefix: token.slice(0, 12),
|
||||||
|
});
|
||||||
|
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_NO_EXPIRY' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||||
|
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_EXPIRED' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireUnused && row.used_at) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'This link has already been used',
|
||||||
|
code: 'TOKEN_ALREADY_USED',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-multer guard for upload routes. Runs the same validation as
|
||||||
|
* loadActionToken but DOES NOT mutate state — it just rejects bad
|
||||||
|
* tokens before multer reads the request body and writes to disk.
|
||||||
|
* Without this, a captured/expired token can DoS disk by spamming
|
||||||
|
* uploads that get rejected post-write.
|
||||||
|
*
|
||||||
|
* Wired in as middleware before `multer.single(...)`.
|
||||||
|
*/
|
||||||
|
function preMulterTokenGuard(tableName) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const token = req.params.token;
|
||||||
|
if (!token || !/^[a-f0-9]{64}$/i.test(token)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid token format' });
|
||||||
|
}
|
||||||
|
const row = await loadActionToken(req, res, { tableName, token, requireUnused: true });
|
||||||
|
if (!row) return; // loadActionToken already responded
|
||||||
|
// Attach for downstream handler — saves a duplicate DB lookup.
|
||||||
|
req.publicTokenRow = row;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('preMulterTokenGuard: unexpected error', { err: err.message });
|
||||||
|
return res.status(500).json({ error: 'Internal error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadActionToken,
|
||||||
|
preMulterTokenGuard,
|
||||||
|
// Exported for tests + future routes that need the same lock
|
||||||
|
// surface (e.g. payment-check actions).
|
||||||
|
_internal: { recordBadAttempt, isIpLocked, badAttempts },
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* resolveLogoFile — resolve a logo to an absolute existing file path.
|
||||||
|
*
|
||||||
|
* The CRM PDFs accept a logo from three different sources and each
|
||||||
|
* source may store the path in a different shape (absolute multer
|
||||||
|
* path, relative URL, bare filename, etc.). Rather than have the PDF
|
||||||
|
* renderer guess, we build an exhaustive candidate list HERE, return
|
||||||
|
* the first existing PNG/JPEG file, and log everything we tried when
|
||||||
|
* we come up empty.
|
||||||
|
*
|
||||||
|
* Why this lives in utils:
|
||||||
|
* - both invoiceService and quoteService need the same resolution
|
||||||
|
* - the renderer (pdfService) should NOT touch the DB or the
|
||||||
|
* filesystem-discovery rules; it just calls doc.image(path)
|
||||||
|
*
|
||||||
|
* Sources we accept (in priority order):
|
||||||
|
* 1. business_profile.logo_path — explicit per-CRM logo
|
||||||
|
* 2. app_settings.branding_logo_path — absolute multer path from
|
||||||
|
* Settings → Branding (preferred — already absolute)
|
||||||
|
* 3. app_settings.branding_logo_url — URL path from the same
|
||||||
|
* branding upload (fallback for older installs)
|
||||||
|
*
|
||||||
|
* For each source we try multiple candidate disk paths:
|
||||||
|
* - The raw value as an absolute path (if absolute)
|
||||||
|
* - STORAGE_PATH joined with the value (stripped of leading "/")
|
||||||
|
* - STORAGE_PATH/uploads/logos/<basename>
|
||||||
|
* - STORAGE_PATH/branding/<basename>
|
||||||
|
* - CWD/storage joined with the value (last-ditch for older
|
||||||
|
* docker-compose configs that didn't set STORAGE_PATH)
|
||||||
|
*
|
||||||
|
* Format filter:
|
||||||
|
* PDFKit only natively decodes PNG + JPEG. SVG/WebP/GIF/TIFF files
|
||||||
|
* are silently skipped here (with a warn log) so the rest of the
|
||||||
|
* PDF still renders rather than crashing on an unsupported format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { getStoragePath } = require('../config/storage');
|
||||||
|
const { getAppSetting } = require('./appSettings');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const SUPPORTED_EXT = /\.(png|jpe?g)$/i;
|
||||||
|
// Formats PDFKit can't embed directly but `sharp` can rasterise into
|
||||||
|
// PNG for us. We transparently convert + cache.
|
||||||
|
const CONVERTIBLE_EXT = /\.(svg|webp|gif|tif|tiff|avif|heif|heic)$/i;
|
||||||
|
|
||||||
|
function generateCandidates(raw, storageRoot) {
|
||||||
|
const value = String(raw || '').trim();
|
||||||
|
if (!value) return [];
|
||||||
|
const stripped = value.replace(/^\/+/, '');
|
||||||
|
const baseName = path.basename(value);
|
||||||
|
// Build candidate set; dedup at the end so we don't stat the same
|
||||||
|
// file twice when the inputs overlap.
|
||||||
|
const candidates = [
|
||||||
|
path.isAbsolute(value) ? value : null,
|
||||||
|
path.join(storageRoot, stripped),
|
||||||
|
path.join(storageRoot, 'uploads', 'logos', baseName),
|
||||||
|
path.join(storageRoot, 'branding', baseName),
|
||||||
|
path.join(process.cwd(), 'storage', stripped),
|
||||||
|
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
|
||||||
|
path.join(process.cwd(), 'storage', 'branding', baseName),
|
||||||
|
].filter(Boolean);
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickExisting(candidates) {
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterise a non-PNG/JPEG source into PNG so PDFKit can embed it.
|
||||||
|
* The output is cached under STORAGE_PATH/cache/logo-png/, keyed by
|
||||||
|
* the source path + mtime + size — re-uploading the SVG invalidates
|
||||||
|
* the cache automatically without us having to clean up old entries.
|
||||||
|
*
|
||||||
|
* Returns the absolute cached PNG path on success, or null when
|
||||||
|
* `sharp` fails (corrupt SVG, unsupported feature inside the SVG,
|
||||||
|
* etc.). The caller logs + falls back to the name-only branch.
|
||||||
|
*/
|
||||||
|
async function rasteriseToPng(sourcePath, storageRoot) {
|
||||||
|
let sharp;
|
||||||
|
try {
|
||||||
|
sharp = require('sharp');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PDF logo rasterisation skipped — sharp not installed', { err: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(sourcePath);
|
||||||
|
const cacheDir = path.join(storageRoot, 'cache', 'logo-png');
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
// Content-addressed cache: sha1(src path + mtime ns + size).
|
||||||
|
// Including mtime means re-uploading the source invalidates the
|
||||||
|
// cache entry naturally.
|
||||||
|
const key = crypto.createHash('sha1')
|
||||||
|
.update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`)
|
||||||
|
.digest('hex');
|
||||||
|
const cachedPath = path.join(cacheDir, `${key}.png`);
|
||||||
|
if (fs.existsSync(cachedPath)) {
|
||||||
|
return cachedPath;
|
||||||
|
}
|
||||||
|
// density: 384 gives a crisp render even when the SVG embeds at
|
||||||
|
// a small intrinsic size (PDFKit's `fit` will downscale, never
|
||||||
|
// upscale). 512px wide is more than enough for a letterhead
|
||||||
|
// logo.
|
||||||
|
await sharp(sourcePath, { density: 384 })
|
||||||
|
.resize({ width: 512, withoutEnlargement: false })
|
||||||
|
.png()
|
||||||
|
.toFile(cachedPath);
|
||||||
|
logger.info('PDF logo rasterised to PNG', { source: sourcePath, cached: cachedPath });
|
||||||
|
return cachedPath;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PDF logo rasterisation failed', {
|
||||||
|
source: sourcePath, err: err.message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} profile the business_profile row (or null)
|
||||||
|
* @returns {Promise<string|null>} absolute path to a usable PNG/JPEG, or null
|
||||||
|
*/
|
||||||
|
async function resolveLogoFile(profile) {
|
||||||
|
const storageRoot = getStoragePath();
|
||||||
|
const raws = [];
|
||||||
|
const profileLogoPath = (profile?.logo_path || '').toString().trim();
|
||||||
|
if (profileLogoPath) raws.push({ source: 'business_profile.logo_path', value: profileLogoPath });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brandingDisk = await getAppSetting('branding_logo_path');
|
||||||
|
if (brandingDisk && typeof brandingDisk === 'string' && brandingDisk.trim()) {
|
||||||
|
raws.push({ source: 'branding_logo_path', value: brandingDisk.trim() });
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brandingUrl = await getAppSetting('branding_logo_url');
|
||||||
|
if (brandingUrl && typeof brandingUrl === 'string' && brandingUrl.trim()) {
|
||||||
|
raws.push({ source: 'branding_logo_url', value: brandingUrl.trim() });
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
if (raws.length === 0) return null;
|
||||||
|
|
||||||
|
for (const { source, value } of raws) {
|
||||||
|
const candidates = generateCandidates(value, storageRoot);
|
||||||
|
const found = pickExisting(candidates);
|
||||||
|
if (!found) continue;
|
||||||
|
if (SUPPORTED_EXT.test(found)) {
|
||||||
|
logger.info('Resolved PDF logo', { source, configured: value, resolved: found });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
if (CONVERTIBLE_EXT.test(found)) {
|
||||||
|
// SVG / WebP / GIF / TIFF / AVIF — rasterise to PNG so PDFKit
|
||||||
|
// can embed it. Cached under STORAGE_PATH/cache/logo-png/ so
|
||||||
|
// repeated PDF renders don't re-encode the same source.
|
||||||
|
const rasterised = await rasteriseToPng(found, storageRoot);
|
||||||
|
if (rasterised) {
|
||||||
|
logger.info('Resolved PDF logo via rasterisation', {
|
||||||
|
source, configured: value, original: found, resolved: rasterised,
|
||||||
|
});
|
||||||
|
return rasterised;
|
||||||
|
}
|
||||||
|
logger.warn('PDF logo rasterisation produced no file; trying next source', {
|
||||||
|
source, configured: value, found,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unknown extension — try anyway, PDFKit may still accept it.
|
||||||
|
logger.warn('PDF logo has unusual extension; attempting to render as-is', {
|
||||||
|
source, configured: value, found,
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('PDF logo not found on disk after trying all sources', {
|
||||||
|
storageRoot,
|
||||||
|
sources: raws.map(({ source, value }) => ({
|
||||||
|
source, value, candidates: generateCandidates(value, storageRoot),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveLogoFile };
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* safePath — path-containment helpers for the contract / quote / invoice
|
||||||
|
* PDF surfaces.
|
||||||
|
*
|
||||||
|
* **Why this exists**
|
||||||
|
*
|
||||||
|
* The audit (#25, #31) flagged that several routes pipe `fs.createReadStream`
|
||||||
|
* on a path read directly from the DB (`contracts.pdf_path`,
|
||||||
|
* `contracts.signed_pdf_path`) and that `attachSignedPdfUpload` accepts
|
||||||
|
* a route-supplied filePath with no containment assertion. The
|
||||||
|
* defence-in-depth concern: if a path ever got into the DB pointing
|
||||||
|
* outside the legitimate storage roots (via a future migration bug,
|
||||||
|
* a hand-edited row, or a SQL-injection regression elsewhere), the
|
||||||
|
* stream would happily read /etc/passwd or any other readable file
|
||||||
|
* for the requesting admin.
|
||||||
|
*
|
||||||
|
* Today the DB paths are written by the service layer and never
|
||||||
|
* accept caller input directly, so the practical exposure is low —
|
||||||
|
* but a 4-line containment check at the read boundary makes the
|
||||||
|
* invariant explicit and protects against future drift.
|
||||||
|
*
|
||||||
|
* **Approach**
|
||||||
|
*
|
||||||
|
* `assertPathInside(absoluteFilePath, allowedRoots)` resolves both
|
||||||
|
* sides to canonical absolute paths via `fs.realpathSync` and
|
||||||
|
* verifies the file path starts with one of the allowed root strings
|
||||||
|
* followed by a path separator (so /storage-evil/ doesn't pass when
|
||||||
|
* /storage/ is allowed). Throws `AppError 403` on violation.
|
||||||
|
*
|
||||||
|
* `realpathSync` resolves symlinks, defeating the obvious attack
|
||||||
|
* (symlink in storage root → /etc/passwd). It throws on missing
|
||||||
|
* files, which is fine — callers already exists-check before stream
|
||||||
|
* via `fs.existsSync`. We re-throw missing-file errors as
|
||||||
|
* AppError 404 to keep the response shape consistent.
|
||||||
|
*
|
||||||
|
* **What the contract surface uses**
|
||||||
|
*
|
||||||
|
* Two roots:
|
||||||
|
* 1. `<cwd>/storage/business-docs/contract/<year>/` — system-stamped
|
||||||
|
* PDFs (immutable as-sent + signed copies).
|
||||||
|
* 2. `<STORAGE_PATH or cwd/storage>/uploads/contracts/signed/` —
|
||||||
|
* wet-upload PDFs (admin or customer-supplied).
|
||||||
|
*
|
||||||
|
* Both roots are constants from the operator's perspective; legitimate
|
||||||
|
* paths always live under one of them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { AppError } = require('./errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the canonical (symlink-followed) absolute path. Throws
|
||||||
|
* AppError 404 when the file is missing on disk; caller handles
|
||||||
|
* the 404 response.
|
||||||
|
*/
|
||||||
|
function realpathOr404(absPath) {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(absPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
||||||
|
throw new AppError('File missing on disk', 404, 'FILE_MISSING');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that `filePath` resolves to a location inside one of
|
||||||
|
* `allowedRoots`. Throws AppError 403 on violation.
|
||||||
|
*
|
||||||
|
* Both inputs are resolved through realpath so symlinks in either
|
||||||
|
* direction are followed before comparison. `allowedRoots` that
|
||||||
|
* don't themselves exist are silently dropped from the check (a
|
||||||
|
* deployment with both quote and contract roots may have the
|
||||||
|
* contract root missing on first boot, for example) — at least one
|
||||||
|
* root MUST exist for the check to allow the path.
|
||||||
|
*/
|
||||||
|
function assertPathInside(filePath, allowedRoots) {
|
||||||
|
if (!filePath) throw new AppError('No path provided', 400);
|
||||||
|
const resolvedFile = realpathOr404(filePath);
|
||||||
|
const resolvedRoots = [];
|
||||||
|
for (const root of allowedRoots) {
|
||||||
|
if (!root) continue;
|
||||||
|
try {
|
||||||
|
const r = fs.realpathSync(root);
|
||||||
|
// Append a separator so /storage/foo doesn't match /storage/foo-evil.
|
||||||
|
resolvedRoots.push(r.endsWith(path.sep) ? r : r + path.sep);
|
||||||
|
} catch (_) {
|
||||||
|
// Root doesn't exist yet — fall through. Next iteration may resolve.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedRoots.length === 0) {
|
||||||
|
// Defensive: refuse rather than allowing free access when no root
|
||||||
|
// exists. Should only happen on a half-provisioned install.
|
||||||
|
throw new AppError('No allowed storage roots configured', 500, 'NO_STORAGE_ROOTS');
|
||||||
|
}
|
||||||
|
const ok = resolvedRoots.some((root) =>
|
||||||
|
resolvedFile === root.slice(0, -1) || resolvedFile.startsWith(root)
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new AppError('Refusing to serve a file outside the storage roots', 403, 'PATH_OUTSIDE_STORAGE');
|
||||||
|
}
|
||||||
|
return resolvedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper that builds the standard contract PDF roots
|
||||||
|
* (system-stamped + wet-upload) and delegates to assertPathInside.
|
||||||
|
* Use from contract PDF stream / read sites.
|
||||||
|
*/
|
||||||
|
function assertContractPdfPath(filePath) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
|
||||||
|
return assertPathInside(filePath, [
|
||||||
|
path.join(cwd, 'storage', 'business-docs', 'contract'),
|
||||||
|
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assertPathInside,
|
||||||
|
assertContractPdfPath,
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* schemaCache — process-local memoisation for `db.schema.hasColumn`.
|
||||||
|
*
|
||||||
|
* **Why this exists**
|
||||||
|
*
|
||||||
|
* The CRM services on `feat/crm` are riddled with `hasColumn` guards
|
||||||
|
* because the schema has been drifting fast (every doc-feature
|
||||||
|
* migration adds a column that older installs may not yet have).
|
||||||
|
* Each call hits information_schema (Postgres) or sqlite_master
|
||||||
|
* (SQLite). On hot paths — `recordCustomerSignature`,
|
||||||
|
* `recordAdminCountersignature`, `getContractById`, the monthly
|
||||||
|
* billing pass — we issue 4–8 hasColumn checks per request, all
|
||||||
|
* for columns whose presence cannot change at runtime.
|
||||||
|
*
|
||||||
|
* The audit flagged this as a perf medium. Caching is safe because:
|
||||||
|
*
|
||||||
|
* 1. Schema-changing operations (migrations, `ALTER TABLE`) only
|
||||||
|
* run at boot via `run-migrations-safe.js`, BEFORE any service
|
||||||
|
* module accepts traffic. The cache is populated lazily after
|
||||||
|
* boot finishes, so the entries reflect post-migration state.
|
||||||
|
*
|
||||||
|
* 2. The Node process is the only schema authority. There's no
|
||||||
|
* sibling process sneaking in `ALTER TABLE` while we serve
|
||||||
|
* requests.
|
||||||
|
*
|
||||||
|
* 3. If a future migration path needs to run mid-flight, it can
|
||||||
|
* call `invalidateSchemaCache()` after the schema change.
|
||||||
|
*
|
||||||
|
* **What we cache**
|
||||||
|
*
|
||||||
|
* Just the boolean answer to `(table, column)`. A miss means the
|
||||||
|
* column doesn't exist on this install; a hit means it does. The
|
||||||
|
* cache key is `${table}.${column}`. There's no TTL — the entry is
|
||||||
|
* valid for the lifetime of the Node process.
|
||||||
|
*
|
||||||
|
* **What we DON'T cache**
|
||||||
|
*
|
||||||
|
* Negative results from `hasTable` failures (the table simply isn't
|
||||||
|
* there) — those go through the underlying call each time. That
|
||||||
|
* scenario is exceptional (table truly missing during a half-applied
|
||||||
|
* migration window) and we want it to surface, not get masked by a
|
||||||
|
* stale cache.
|
||||||
|
*
|
||||||
|
* **API**
|
||||||
|
*
|
||||||
|
* const { hasColumnCached, invalidateSchemaCache } = require('../utils/schemaCache');
|
||||||
|
* if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Drop-in replacement for `db.schema.hasColumn(...)` calls. The
|
||||||
|
* existing helper signature returns a Promise<boolean> so async
|
||||||
|
* call-sites need no shape change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
async function hasColumnCached(table, column) {
|
||||||
|
const key = `${table}.${column}`;
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
// Resolve via the underlying schema API. We deliberately don't
|
||||||
|
// catch errors here — if the call throws (e.g. DB connection lost
|
||||||
|
// mid-boot), the error surfaces to the caller exactly as it would
|
||||||
|
// have without the cache.
|
||||||
|
const present = await db.schema.hasColumn(table, column);
|
||||||
|
cache.set(key, present);
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every cached entry. Call this after a runtime schema change
|
||||||
|
* (rare — only the dev tooling does this today). Safe to call any
|
||||||
|
* time; the next hasColumnCached lookup will re-resolve.
|
||||||
|
*/
|
||||||
|
function invalidateSchemaCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop entries for a single table. Useful when only one table was
|
||||||
|
* altered and other tables' caches are still valid.
|
||||||
|
*/
|
||||||
|
function invalidateSchemaCacheForTable(table) {
|
||||||
|
const prefix = `${table}.`;
|
||||||
|
for (const k of cache.keys()) {
|
||||||
|
if (k.startsWith(prefix)) cache.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasColumnCached,
|
||||||
|
invalidateSchemaCache,
|
||||||
|
invalidateSchemaCacheForTable,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pdf
|
||||||
Reference in New Issue
Block a user