Merge pull request #555 from Luca-Timo/feat/crm-pr

feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
This commit is contained in:
Paul Nothaft
2026-05-29 11:27:23 +02:00
committed by GitHub
209 changed files with 47985 additions and 747 deletions
@@ -0,0 +1,116 @@
/**
* Schema-shape regression net for the CRM consolidated migration.
*
* Pins the table/column layout that the route + service layer expect
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
* forward-reference deferral added in this PR), but it doesn't notice
* if a future edit silently drops a column the service code reads —
* SQLite would just return undefined and the broken behavior would
* land on beta.
*
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
* so a rename or removal there fails the test instead of silently
* breaking the lineage card.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('CRM schema after core migrations', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('table layout', () => {
const expectedTables = [
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
'events', 'document_sequences',
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
'customer_hour_entries',
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
'event_payment_plans',
];
it.each(expectedTables)('has table %s', async (table) => {
expect(await db.schema.hasTable(table)).toBe(true);
});
});
describe('deal_uuid lineage columns', () => {
// Every document in one engagement shares a deal_uuid — the
// lineage card joins on it. Drop the column anywhere in the chain
// and the card silently returns partial data.
it.each(['quotes', 'contracts', 'invoices'])(
'%s has deal_uuid column',
async (table) => {
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
}
);
// The back-pointer FKs were the source of the schema-drift bug
// we fixed in this PR (forward references). Pin them.
it('quotes has converted_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
});
it('invoices has source_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
});
it('invoices has source_quote_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
});
});
describe('Storno discriminator columns', () => {
// kind='storno' + cancels_invoice_id + negative totals are the
// shape every aggregate filter relies on (feedback_storno_filter_
// everywhere). Pin the columns so a rename doesn't silently break
// every revenue report.
it('invoices has kind discriminator', async () => {
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
});
it('invoices has cancels_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
});
it('invoices has replaces_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
});
});
describe('Event time columns (migration 137)', () => {
// The admin calendar reads these to render timed vs. full-day
// tiles. Per the feedback_migration_preserve_visuals rule, the
// default has to be `is_full_day=true` so existing rows keep
// their pre-migration visual.
it('events has event_time_start', async () => {
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
});
it('events has event_time_end', async () => {
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
});
it('events has is_full_day', async () => {
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
});
});
describe('seed paths', () => {
it('admin + customer seed inserts cleanly', async () => {
const { adminId, customerId } = await seedMinimal(db);
expect(adminId).toBeTruthy();
expect(customerId).toBeTruthy();
const admin = await db('admin_users').where({ id: adminId }).first();
const customer = await db('customer_accounts').where({ id: customerId }).first();
expect(admin.email).toBe('[email protected]');
expect(customer.email).toBe('[email protected]');
});
});
});
@@ -0,0 +1,79 @@
/**
* Negative line items (Rabatt / manual discount lines) are accepted
* end-to-end as long as the resulting total stays ≥ 0. When the
* discount would drive the total negative, the service rejects with
* a clear, code-tagged error so the admin is steered to Storno for
* credit-note workflows.
*
* Touches the actual createInvoice / createQuote service paths so a
* future change to either computeTotals or the guard fires this test.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(30000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
let cleanup;
let adminId;
let customerId;
let invoiceService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
invoiceService = require('../../src/services/invoiceService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Quote-side coverage of the symmetric validator + guard is
// deliberately omitted: createQuote's init path takes ~30 s under
// this harness (something in pdfService / emailProcessor cold-
// require), which would push the suite well past CI's per-test
// budget. The shape of the guard is identical to the invoice one
// covered below; a future change to extract the slow init or to
// stub it for tests should re-enable a parallel quote test.
describe('invoices', () => {
it('accepts a negative-price line and computes the net correctly', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
],
}, adminId);
expect(Array.isArray(invoiceIds)).toBe(true);
expect(invoiceIds.length).toBe(1);
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
expect(row.net_amount_minor).toBe(15000);
expect(row.total_amount_minor).toBe(15000);
});
it('rejects when the discount drives the total negative', async () => {
await expect(invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
],
}, adminId)).rejects.toMatchObject({
code: 'INVOICE_TOTAL_NEGATIVE',
statusCode: 400,
});
});
});
});
@@ -0,0 +1,98 @@
/**
* Boot-time email-template self-heal:
* 1. Seeds the CRM / contract / event-reminder templates on an
* install that's never had them before.
* 2. Recovers email_queue rows that previously exhausted their
* retries because their template was missing.
*
* The failure that triggered this fix (2026-05-27) had Ralf's beta
* box failing every `quote_sent` / `invoice_sent` send for ~14h
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
* defined but never called. After 3 retries the rows sat in
* status='pending' forever; nothing in the admin UI signalled the
* problem. Both halves of that regression are covered here.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('email template self-heal at boot', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
// 107_crm_consolidated documents the deliberate split (templates
// are self-healed at runtime, not inserted by the migration).
const before = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(before).toEqual([]);
// Seed a stuck queue row that mirrors what we found on Ralf's box:
// quote_sent send attempted 3 times, each time failed because the
// template didn't exist, queue processor gave up.
const queueRowIds = await db('email_queue').insert({
recipient_email: '[email protected]',
email_type: 'quote_sent',
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
status: 'pending',
retry_count: 3,
error_message: "Email template 'quote_sent' not found",
created_at: new Date(),
}).returning('id');
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
// Also seed an UNRELATED stuck row (different template, NOT one
// we're going to insert) to confirm the recovery is targeted —
// it must not blanket-reset every retry-exhausted row.
const unrelatedIds = await db('email_queue').insert({
recipient_email: '[email protected]',
email_type: 'some_other_template',
email_data: JSON.stringify({}),
status: 'pending',
retry_count: 3,
error_message: 'SMTP timeout',
created_at: new Date(),
}).returning('id');
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
// The seeders use module-level caches (`_seeded = true`). When
// jest runs this test in isolation that cache starts fresh; in
// the full suite no other test currently calls these seeders, so
// the first call here also runs the real work. Reset the cache
// defensively in case a future test changes that.
jest.resetModules();
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
// Templates landed.
expect(result.seeded).toEqual(expect.arrayContaining([
'quote_sent', 'invoice_sent', 'storno_issued',
]));
const after = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
// Stuck quote_sent row was recovered.
expect(result.recovered).toBeGreaterThanOrEqual(1);
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
expect(recoveredRow.retry_count).toBe(0);
expect(recoveredRow.error_message).toBeNull();
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
// Unrelated stuck row was NOT touched.
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
expect(unrelatedRow.retry_count).toBe(3);
expect(unrelatedRow.error_message).toBe('SMTP timeout');
});
});
@@ -0,0 +1,121 @@
/**
* Test harness for CRM integration tests.
*
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
* directly (bypassing knex's Migrator — its exclusive write lock
* deadlocks 001_init's nested `initializeDatabase()` call), and
* exposes a small helper for seeding the minimal row set that the
* quote/contract/invoice services need to operate.
*
* Usage:
*
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
*
* beforeAll(async () => {
* ({ db, cleanup } = await bootCrmDb());
* ({ adminId, customerId } = await seedMinimal(db));
* });
* afterAll(async () => { await cleanup(); });
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
async function runCoreMigrations(db) {
await db.schema.createTable('migrations', (t) => {
t.increments('id').primary();
t.string('filename').unique().notNullable();
t.timestamp('applied_at').defaultTo(db.fn.now());
});
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
const files = (await fs.promises.readdir(coreDir))
.filter((f) => f.endsWith('.js'))
.sort();
for (const f of files) {
const mod = require(path.join(coreDir, f));
if (typeof mod.up === 'function') {
await mod.up(db);
}
await db('migrations').insert({ filename: f });
}
}
/**
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
* Caller must invoke cleanup() in afterAll to release the SQLite file
* and the temp directory.
*/
async function bootCrmDb() {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
// No jest.resetModules() — every service the test later requires
// must share THIS db instance. Two module copies on one SQLite file
// each open their own knex pool and the SQLite write lock deadlocks
// the second one acquiring a connection. Caller is responsible for
// setting TEST_DATABASE_PATH before the first require of db.js
// (which knexfile reads at module-init time); bootCrmDb only works
// when invoked before any service import.
const { db } = require('../../../src/database/db');
await runCoreMigrations(db);
return {
db,
tmpDir,
cleanup: async () => {
try { await db.destroy(); } catch (_) {}
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
},
};
}
/**
* Seed the minimal row set that quote/contract/invoice services
* dereference on creation: an admin user, an active customer, a
* business_profile row, and the app_settings keys the services read.
*
* Returns the ids the caller will pass into service calls.
*/
async function seedMinimal(db) {
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
const adminInsert = await db('admin_users').insert({
username: 'tester', email: '[email protected]',
password_hash: passwordHash, must_change_password: false,
created_at: new Date(),
}).returning('id');
const adminId = adminInsert[0]?.id ?? adminInsert[0];
// business_profile is a singleton; the row is seeded by migration 107
// for fresh installs. Defensive: insert if missing.
const profile = await db('business_profile').first();
if (!profile) {
await db('business_profile').insert({
legal_name: 'Test Studio',
default_currency: 'CHF',
default_locale: 'de',
});
}
const customerInsert = await db('customer_accounts').insert({
email: '[email protected]',
display_name: 'Test Customer',
password_hash: passwordHash,
preferred_language: 'de',
is_active: 1,
created_at: new Date(),
}).returning('id');
const customerId = customerInsert[0]?.id ?? customerInsert[0];
return { adminId, customerId };
}
module.exports = { bootCrmDb, seedMinimal };
@@ -0,0 +1,105 @@
/**
* Unit tests for the recipient resolver that routes invoice / Storno /
* reminder emails to a bookkeeper address when one is configured,
* while keeping the decision-maker (primary email) on CC.
*
* Pure helper, no DB, no side effects.
*/
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
describe('resolveBillingRecipients', () => {
it('routes to the primary email when no billing_email is set', () => {
expect(resolveBillingRecipients({ email: '[email protected]' }, null))
.toEqual({ to: '[email protected]', cc: undefined });
});
it('routes to billing_email and CCs the primary when both are set', () => {
expect(resolveBillingRecipients({
email: '[email protected]',
billing_email: '[email protected]',
}, null)).toEqual({
to: '[email protected]',
cc: ['[email protected]'],
});
});
it('folds the per-document cc_pdf_email into the CC list', () => {
expect(resolveBillingRecipients({
email: '[email protected]',
billing_email: '[email protected]',
}, '[email protected]')).toEqual({
to: '[email protected]',
cc: ['[email protected]', '[email protected]'],
});
});
it('uses cc_pdf_email alone when there is no billing_email', () => {
expect(resolveBillingRecipients({
email: '[email protected]',
}, '[email protected]')).toEqual({
to: '[email protected]',
cc: ['[email protected]'],
});
});
it('does not CC the primary onto itself when billing_email equals email', () => {
expect(resolveBillingRecipients({
email: '[email protected]',
billing_email: '[email protected]',
}, null)).toEqual({
to: '[email protected]',
cc: undefined,
});
});
it('is case-insensitive when deduping addresses', () => {
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
// practice every mail server treats them as insensitive — and the
// admin entering "[email protected]" in one field and
// "[email protected]" in another should not produce two copies.
expect(resolveBillingRecipients({
email: '[email protected]',
billing_email: '[email protected]',
}, '[email protected]')).toEqual({
to: '[email protected]',
cc: ['[email protected]'],
});
});
it('trims whitespace around the addresses', () => {
expect(resolveBillingRecipients({
email: ' [email protected] ',
billing_email: ' [email protected]\n',
}, '\[email protected] ')).toEqual({
to: '[email protected]',
cc: ['[email protected]', '[email protected]'],
});
});
it('treats empty-string billing_email as not set', () => {
expect(resolveBillingRecipients({
email: '[email protected]',
billing_email: '',
}, null)).toEqual({
to: '[email protected]',
cc: undefined,
});
});
it('returns an empty To when neither email nor billing_email is set', () => {
// Caller is responsible for surfacing this — emailProcessor's own
// validation will reject the empty recipient. The helper just
// refuses to crash.
expect(resolveBillingRecipients({}, null))
.toEqual({ to: '', cc: undefined });
});
it('tolerates a null customer without throwing', () => {
// Per-doc cc alone is never promoted to To: — it stays
// supplemental. A missing customer is a caller bug; we just refuse
// to crash and let emailProcessor reject the empty recipient.
expect(resolveBillingRecipients(null, '[email protected]'))
.toEqual({ to: '', cc: undefined });
});
});
@@ -0,0 +1,109 @@
/**
* Unit tests for the pure helpers in contractService (migration 130).
*
* The DB-bound CRUD paths (createContract / sendContract /
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
* manual QA via the admin + public routes. This file covers the
* deterministic helpers so regressions in placeholder substitution or
* section ordering surface before they leak into a rendered contract.
*
* The service pulls in DB-bound peers (businessProfileService,
* pdfService, emailProcessor) at the top level. We stub the DB layer
* + the side-effect peers so the require chain doesn't try to connect
* to anything; the helpers under test are pure.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(),
}));
jest.mock('../../src/services/pdfService', () => ({
renderContractToBuffer: jest.fn(),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(),
}));
const { _internal } = require(servicePath);
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
describe('renderTemplatedBody', () => {
it('substitutes simple {{var}} placeholders', () => {
expect(renderTemplatedBody(
'Hello {{name}}, due in {{net_days}} days.',
{ name: 'Alice', net_days: 30 },
)).toBe('Hello Alice, due in 30 days.');
});
it('preserves unknown placeholders literally so admins notice missing fields', () => {
expect(renderTemplatedBody(
'Bill from {{issuer}} to {{customer_name}}',
{ issuer: 'PicPeak GmbH' },
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
});
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
expect(renderTemplatedBody(
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
{ has_skonto: true, pct: 2 },
)).toBe('Skonto: 2 % on early payment');
});
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
expect(renderTemplatedBody(
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
{ net_days: 30, has_skonto: false, pct: 2 },
)).toBe('Net 30 d.');
});
it('treats missing variables in {{#if}} as falsy', () => {
expect(renderTemplatedBody(
'A{{#if missing}}B{{/if}}C',
{ unrelated: 'foo' },
)).toBe('AC');
});
it('handles empty strings and missing variables map gracefully', () => {
expect(renderTemplatedBody('', { x: 1 })).toBe('');
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
});
it('passes through non-string input unchanged', () => {
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
});
it('substitutes numeric and falsy variable values as strings', () => {
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
});
});
describe('SECTIONS_ORDER', () => {
it('matches the canonical six-section order locked in the spec', () => {
expect(SECTIONS_ORDER).toEqual([
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
]);
});
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
const blocksService = require('../../src/services/contractBlocksService');
expect([...SECTIONS_ORDER].sort()).toEqual(
[...blocksService.ALLOWED_SECTIONS].sort(),
);
});
});
@@ -0,0 +1,213 @@
/**
* Tests for the passive-customer surface:
*
* - createDirect inserts a customer with password_hash=null,
* queueEmail is never called, race-guard rejects duplicates
* - createInvitation allows passing through when the existing
* customer is passive (promotion path); still rejects when the
* existing customer is active (real duplicate)
* - acceptInvitation upserts into an existing passive customer
* row (preserving id) when one exists; inserts a fresh row
* otherwise; still rejects when the existing customer is active
*
* Pure unit tests — db is mocked via a thenable chain so we can
* inspect every insert / update payload without spinning up SQLite.
*/
// ----- mock db chain --------------------------------------------------
//
// We need fine-grained control over which row each table-name returns
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
// what `.update(...)` resolves to. The chain is a thenable proxy that
// terminates on the call we care about.
const tableSeeds = {}; // table → first-row return value
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
const updateCalls = []; // [{ table, where, updates }]
let nextInsertId = 1000;
function resetMockDb() {
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
for (const k of Object.keys(insertResults)) delete insertResults[k];
updateCalls.length = 0;
nextInsertId = 1000;
}
function makeChain(tableName) {
const chain = {
_whereClauses: [],
where(...args) { this._whereClauses.push(args); return this; },
whereNull() { return this; },
whereNot() { return this; },
andWhere() { return this; },
orderBy() { return this; },
leftJoin() { return this; },
groupBy() { return this; },
select(...args) {
// listCustomers / search → return seeded array
const seeded = tableSeeds[`${tableName}__select`];
return Promise.resolve(seeded || []);
},
first() {
const seeded = tableSeeds[tableName];
return Promise.resolve(seeded);
},
insert(payload) {
const id = nextInsertId++;
insertResults[tableName] = insertResults[tableName] || [];
insertResults[tableName].push({ ...payload, id });
const result = { id };
return {
returning() { return Promise.resolve([result]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
update(updates) {
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
return Promise.resolve(1);
},
del() { return Promise.resolve(1); },
raw() { return this; },
};
return chain;
}
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
mockDbFn.raw = jest.fn();
mockDbFn.transaction = async (cb) => cb(mockDbFn);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
const mockQueueEmail = jest.fn(async () => {});
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: mockQueueEmail,
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: { default_locale: 'de' },
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const customerAccountsService = require('../../src/services/customerAccountsService');
beforeEach(() => {
resetMockDb();
mockQueueEmail.mockClear();
});
// --------------------------------------------------------------------
// createDirect
// --------------------------------------------------------------------
describe('createDirect', () => {
it('inserts a customer with password_hash=null, is_active=true', async () => {
tableSeeds.customer_accounts = undefined; // no duplicate
const result = await customerAccountsService.createDirect({
email: '[email protected]',
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
createdByAdminId: 5,
});
expect(result.id).toBeDefined();
const inserted = insertResults.customer_accounts[0];
expect(inserted.email).toBe('[email protected]');
expect(inserted.password_hash).toBeNull();
expect(inserted.created_by_admin_id).toBe(5);
expect(inserted.first_name).toBe('Anna');
expect(inserted.company_name).toBe('ACME GmbH');
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
expect([true, 1, '1']).toContain(inserted.is_active);
});
it('defaults preferred_language from the business profile', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
});
it('honours preferred_language when the admin pre-fills it', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: { preferred_language: 'fr' },
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
});
it('rejects when a customer with the email already exists', async () => {
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: 'whatever' };
await expect(customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/already exists/);
});
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
tableSeeds.customer_accounts = undefined;
await expect(customerAccountsService.createDirect({
email: '',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/Email is required/);
});
it('NEVER queues an invitation email (regression guard)', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: '[email protected]',
prefill: {},
createdByAdminId: 1,
});
expect(mockQueueEmail).not.toHaveBeenCalled();
});
});
// --------------------------------------------------------------------
// createInvitation passive-allowance behaviour
// --------------------------------------------------------------------
describe('createInvitation — duplicate-email guard', () => {
it('still rejects when the existing customer has a password (real duplicate)', async () => {
tableSeeds.customer_accounts = { id: 1, email: '[email protected]', password_hash: 'hash' };
await expect(customerAccountsService.createInvitation({
email: '[email protected]',
invitedById: 5,
prefill: null,
})).rejects.toThrow(/already exists/);
expect(mockQueueEmail).not.toHaveBeenCalled();
});
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: null };
// no pending invitation
// The chain returns `tableSeeds.customer_invitations` for .first()
// and we haven't seeded one, so it's undefined → allowed through.
const out = await customerAccountsService.createInvitation({
email: '[email protected]',
invitedById: 9,
prefill: { first_name: 'Anna' },
});
expect(out.id).toBeDefined();
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,165 @@
/**
* Unit tests for the pure helpers in customerHoursService (migration
* 129). The CRUD paths themselves are exercised end-to-end via the
* admin/customers routes during manual QA; this file covers the
* deterministic logic so regressions in the rate / duration / lock
* resolution show up before they hit a real invoice.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
// The service imports invoiceService which pulls in the DB. We don't
// need either for the pure helpers — stub the DB layer so the
// require chain doesn't try to connect to anything.
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/invoiceService', () => ({}));
const { _internal } = require(servicePath);
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
describe('computeDurationMinutes', () => {
it('returns minute count for a basic window', () => {
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
});
it('handles single-minute precision', () => {
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
});
it('rejects malformed input', () => {
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
});
it('rejects zero or negative duration', () => {
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
});
});
describe('resolveEffectiveRate', () => {
it('prefers the per-entry override when set', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 20000 },
{ hourly_rate_minor: 15000 },
)).toBe(20000);
});
it('falls back to the customer default when no override', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
)).toBe(15000);
});
it('throws when both override and customer rate are unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
)).toThrow(/No hourly rate/);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 0 },
{ hourly_rate_minor: 15000 },
)).toBe(0);
});
});
describe('isEntryLocked', () => {
it('unbilled entry → not locked', () => {
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
});
it('monthly draft → not locked (still accumulating)', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('standalone draft with no send time → not locked', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('future-scheduled draft → not locked', () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
)).toBe(false);
});
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
)).toBe(true);
});
it('sent / paid / overdue / cancelled → locked', () => {
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
)).toBe(true);
}
});
it('entry references a deleted invoice (null) → treat as unbilled', () => {
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
});
});
describe('buildLineItemFromEntry', () => {
const baseEntry = {
entry_date: '2026-05-20',
start_time: '09:00',
end_time: '11:30',
duration_minutes: 150,
description: 'Editing wedding photos',
};
it('formats the description per spec', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h): Editing wedding photos');
});
it('omits the colon when no description', () => {
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h)');
});
it('quantity is decimal hours with 2 places', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.quantity).toBeCloseTo(2.5, 5);
});
it('line_total rounds correctly for non-clean durations', () => {
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
const li = buildLineItemFromEntry(
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
10000,
);
expect(li.line_total_minor).toBe(2500);
});
it('zero-rate line items produce a zero total without exploding', () => {
const li = buildLineItemFromEntry(baseEntry, 0);
expect(li.line_total_minor).toBe(0);
expect(li.unit_price_minor).toBe(0);
});
});
@@ -0,0 +1,119 @@
/**
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
* gates the migration-137 calendar time columns on events.
*
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
* helper and write through hasColumnCached guards; those are
* exercised in manual QA. This file pins the contract so a future
* tweak to the validation rules doesn't silently break it.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
// Stub every DB-bound peer so the require chain doesn't try to open
// a knex connection. The helper under test is pure.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
const { normaliseEventTimeTriple } = require(servicePath);
describe('normaliseEventTimeTriple', () => {
it('defaults to full-day when is_full_day is undefined', () => {
expect(normaliseEventTimeTriple({})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('forces times to null when is_full_day is true even if times are supplied', () => {
expect(normaliseEventTimeTriple({
is_full_day: true,
event_time_start: '10:00',
event_time_end: '12:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('accepts a valid timed range when is_full_day is false', () => {
expect(normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '09:30',
event_time_end: '17:00',
})).toEqual({
event_time_start: '09:30',
event_time_end: '17:00',
is_full_day: false,
});
});
it('throws when is_full_day is false and start is missing/malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '25:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '9:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
});
it('throws when end is missing or malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '12:99',
})).toThrow(/HH:MM/);
});
it('throws when end is at or before start', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '10:00',
})).toThrow(/after/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '15:00',
event_time_end: '10:00',
})).toThrow(/after/);
});
it('parses string boolean flag', () => {
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
// the helper consumes them transparently.
expect(normaliseEventTimeTriple({
is_full_day: 'false',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: '08:00',
event_time_end: '09:00',
is_full_day: false,
});
expect(normaliseEventTimeTriple({
is_full_day: '1',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
});
@@ -0,0 +1,122 @@
/**
* Tests for the migration-119 hierarchy support in invoiceService —
* the shared helpers come from quoteService._internal (validated in
* quoteService.hierarchy.test.js), so we focus here on the
* invoice-specific seams:
*
* - quote → invoice cloner preserves parent_position + details_text
* across the conversion
* - the cloner's installment "adjustment" line only reconciles
* against TOP-LEVEL cloned items (sub-items don't contribute to
* net so they can't appear in the sum)
*
* Pure helper, no DB.
*/
const quoteService = require('../../src/services/quoteService');
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
describe('quote → invoice cloner shape', () => {
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
// take source quote line items (with parent_position) and produce the
// `cloned` array that's passed into insertLineItemsHierarchical.
function modelCloner(sourceLines) {
return sourceLines.map((li) => ({
position: parseInt(li.position, 10),
quantity: Number(li.quantity || 1),
description: li.description,
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
discount_percent: Number(li.discount_percent || 0),
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
details_text: li.details_text || null,
}));
}
it('preserves parent_position so the hierarchy carries across conversion', () => {
const source = [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
];
const cloned = modelCloner(source);
expect(cloned[0].parent_position).toBeNull();
expect(cloned[1].parent_position).toBe(1);
expect(cloned[2].parent_position).toBe(1);
// The cloned shape passes hierarchy validation — same positions
// means the same parent links work without any remap.
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
});
it('preserves details_text verbatim', () => {
const source = [
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
details_text: 'Includes online gallery + 100 high-res downloads.' },
];
const cloned = modelCloner(source);
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
});
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
// Recreate the inner math from scheduleInvoicesForEvent: sum
// only line_total_minor where parent_position is null. Sub-items
// would otherwise double-count and skew the adjustment.
//
// Note: the cloner stores raw line_total_minor on each row from
// the source quote. By the time this sum runs, the parent's
// line_total_minor has already been resolved upstream (via
// computeTotals on the quote at save time) — so iterating
// top-level only sums the resolved parent totals + standalone
// top-level items. Sub-items never contribute here regardless of
// whether their parent's total was auto-resolved or not.
const cloned = modelCloner([
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
// Sub-items €150 + €200 + €100 — shown for transparency, must
// NOT enter the reconciliation sum.
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
// Another top-level €100
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
]);
const clonedSum = cloned
.filter((x) => x.parent_position == null)
.reduce((s, x) => s + x.line_total_minor, 0);
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
expect(clonedSum).toBe(55000);
});
});
describe('insertLineItemsHierarchical for invoices', () => {
function makeTrxMock() {
let nextId = 200;
const inserts = [];
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
});
return { trx, inserts };
}
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
]);
expect(inserts).toHaveLength(2);
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
// Parent inserted first, sub-item second with parent_line_item_id
// matching the parent's synthesised id.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBe(200);
});
});
@@ -0,0 +1,322 @@
/**
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
*
* Validation tests run against the pure validator directly. Orchestration
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
* — chains are queued per table and assertions probe insert/update/delete
* call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => ({
claimNextSequence: jest.fn(async () => 42),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('validateInstallmentPlanInput', () => {
const { validateInstallmentPlanInput } = invoiceService;
it('throws on empty array', () => {
expect(() => validateInstallmentPlanInput([]))
.toThrow(/non-empty array/);
});
it('throws on non-array', () => {
expect(() => validateInstallmentPlanInput(null))
.toThrow(/non-empty array/);
});
it('throws on out-of-range percent', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
expect(() => validateInstallmentPlanInput([
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
});
it('throws on unknown trigger', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
])).toThrow(/invalid trigger/);
});
it('throws when percents do not sum to 100', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 50, trigger: 'before_event', offset_days: -7 },
])).toThrow(/must sum to 100/);
});
it('accepts a valid three-row plan with mixed triggers', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
])).not.toThrow();
});
it('tolerates 0.001 rounding drift in the sum', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
])).not.toThrow();
});
});
describe('updateInstallmentPlan — guards', () => {
beforeEach(() => resetChains());
const goodPlan = [
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
];
it('rejects when dealUuid is missing', async () => {
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the deal has no invoices', async () => {
pickChainFor('invoices')._selectResult = [];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 404 });
});
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
});
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
});
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
});
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
const badPlan = [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 30, trigger: 'before_event', offset_days: -7 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
});
});
describe('updateInstallmentPlan — reshape (smoke)', () => {
beforeEach(() => resetChains());
const sibling = (overrides) => ({
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
shipping_amount_minor: 0, vat_rate: 7.7,
customer_account_id: 5, source_quote_id: null, event_id: null,
event_name: 'Wedding', event_date: '2026-08-15',
language: 'de', currency: 'CHF',
issue_date: '2026-05-25', due_date: '2026-06-24',
cc_pdf_email: null,
payment_net_days_template_id: null, payment_timing_template_id: null,
payment_term_snapshot: null,
...overrides,
});
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2, 3]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([]);
// Sequence helper never touched on a same-count reshape.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('invoices')._insertResult = [{ id: 99 }];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created.length).toBe(1);
expect(result.deleted).toEqual([]);
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
});
it('shrinks 3→2 by deleting the third row + its line items', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([3]);
// Line items + invoice rows deleted on the trimmed sibling.
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
expect(pickChainFor('invoices').del).toHaveBeenCalled();
});
});
@@ -0,0 +1,374 @@
/**
* Tests for invoiceService lock + state-transition guards.
*
* Focuses on the rules that protect tax/audit integrity:
* - reissueInvoice refuses to act on `scheduled` (use Edit)
* - reissueInvoice cancels + clones any other status
* - releaseForDelivery refuses to act on non-pending_delivery
* - recordPaymentCheckAction refuses already-used / expired tokens
*
* db is deep-mocked so the tests are deterministic and fast.
*/
// Mock db chain: each table call returns a builder whose methods
// chain (return `this`) until a terminal method (.first / .update /
// .insert / .returning) resolves with the queued value.
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
_allRows: [],
// knex chains are thenable — awaiting them runs the query and
// resolves with the row set. We mirror that so callers can
// `await trx('t').where(...).orderBy(...)` and get an array.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
// select is both chainable (`.select('col').first()`) and awaitable
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
// db.transaction(cb) runs the callback with a "trx" — for our
// purposes the same chain factory works as trx.
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('invoiceService.reissueInvoice', () => {
beforeEach(() => resetChains());
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
await expect(invoiceService.reissueInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('throws when the source invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.reissueInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('cancels the original and creates a new row when status is sent', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'sent', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
shipping_amount_minor: 0, cc_pdf_email: null,
business_bank_account_id: null, qr_format: null,
payment_term_template_id: null, event_id: null,
source_quote_id: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, is_active: 1, feature_bills: 1,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const result = await invoiceService.reissueInvoice(1, 42);
expect(result.id).toBeDefined();
expect(result.replaces).toBe(1);
});
});
describe('invoiceService.createStorno', () => {
beforeEach(() => resetChains());
it('rejects when the source invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.createStorno(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('rejects when the source is already cancelled (no double-Storno)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects when asked to Storno a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('inserts a Storno row and flips the original on a sent invoice', async () => {
// Original is `sent`, no line items, no event.
const invoicesChain = pickChainFor('invoices');
invoicesChain._firstValue = {
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
net_amount_minor: 30000, vat_amount_minor: 2310,
total_amount_minor: 32310, shipping_amount_minor: 0,
cc_pdf_email: null, event_id: null,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const stornoId = await invoiceService.createStorno(1, 42);
expect(stornoId).toBeDefined();
// The mock chain's .update() is called twice on `invoices`:
// 1) `.insert(...).returning('id')` for the Storno row
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
// We just verify the helpers were exercised on the right table.
expect(invoicesChain.insert).toHaveBeenCalled();
expect(invoicesChain.update).toHaveBeenCalled();
// The Storno insert payload should carry kind='storno' and
// negated row-level totals. Inspect the first insert call's
// payload to confirm.
const insertedRow = invoicesChain.insert.mock.calls[0][0];
expect(insertedRow.kind).toBe('storno');
expect(insertedRow.net_amount_minor).toBe(-30000);
expect(insertedRow.vat_amount_minor).toBe(-2310);
expect(insertedRow.total_amount_minor).toBe(-32310);
expect(insertedRow.cancels_invoice_id).toBe(1);
expect(insertedRow.status).toBe('scheduled');
// No payment instrument on a Storno.
expect(insertedRow.business_bank_account_id).toBeNull();
expect(insertedRow.qr_format).toBeNull();
expect(insertedRow.payment_term_template_id).toBeNull();
// Storni have no real payment due, but the schema's NOT NULL
// constraint on due_date forces a value — we mirror issue_date.
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
});
});
describe('invoiceService.cancelInvoice', () => {
beforeEach(() => resetChains());
it('rejects when the invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.cancelInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
const result = await invoiceService.cancelInvoice(1, 42);
expect(result).toEqual({ cancelled: true, stornoId: null });
});
});
describe('invoiceService.releaseForDelivery', () => {
beforeEach(() => resetChains());
it('refuses when status is not pending_delivery', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
await expect(invoiceService.releaseForDelivery(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
it('404s when the invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.releaseForDelivery(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe('invoiceService.recordPaymentCheckAction', () => {
beforeEach(() => resetChains());
it('rejects invalid actions', async () => {
await expect(invoiceService.recordPaymentCheckAction({
token: 'abc', action: 'foo',
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the token is not on file', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 404 });
});
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: new Date(),
expires_at: new Date(Date.now() + 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
});
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() - 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
});
it('rejects partial with amount <= 0', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects partial with amount > outstanding', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
})).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('invoiceService.queuePaymentCheckEmail', () => {
beforeEach(() => resetChains());
it('skips when invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'not_found' });
});
it('skips when status is not sent/overdue', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'paid',
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res.sent).toBe(false);
expect(res.reason).toMatch(/wrong_status_paid/);
});
it('respects the 24h throttle', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
});
it('bypasses the throttle when skipThrottle=true', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
customer_account_id: 5,
created_by_admin_id: 42,
total_amount_minor: 10000,
currency: 'CHF',
language: 'de',
reminder_level: 0,
due_date: '2026-05-01',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: '[email protected]', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 5, email: '[email protected]', display_name: 'Test' };
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
expect(res.sent).toBe(true);
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
});
});
@@ -0,0 +1,236 @@
/**
* Tests for the createBaseDocument + getPageMetrics helpers — the
* shared PDF factory used by quote/invoice rendering AND by the
* upcoming tax-report renderer. These verify orientation handling
* and font defaults without touching DB or filesystem.
*/
const pdfService = require('../../src/services/pdfService');
describe('getPageMetrics', () => {
it('returns portrait A4 metrics by default', () => {
const p = pdfService.getPageMetrics();
expect(p.width).toBeCloseTo(595.28, 1);
expect(p.height).toBeCloseTo(841.89, 1);
expect(p.contentWidth).toBeCloseTo(515.28, 1);
});
it('returns portrait when orientation is "portrait"', () => {
const p = pdfService.getPageMetrics('portrait');
expect(p.width).toBeLessThan(p.height);
});
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
const p = pdfService.getPageMetrics('landscape');
expect(p.width).toBeCloseTo(841.89, 1);
expect(p.height).toBeCloseTo(595.28, 1);
expect(p.contentWidth).toBeCloseTo(761.89, 1);
expect(p.width).toBeGreaterThan(p.height);
});
it('ignores unknown orientation values (falls back to portrait)', () => {
const p = pdfService.getPageMetrics('upside-down');
expect(p.width).toBeLessThan(p.height);
});
});
describe('createBaseDocument', () => {
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
const { doc, page, fonts } = pdfService.createBaseDocument();
expect(doc).toBeDefined();
expect(typeof doc.on).toBe('function');
expect(typeof doc.font).toBe('function');
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
});
it('produces a landscape document when orientation is "landscape"', () => {
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
expect(page.width).toBeGreaterThan(page.height);
// PDFKit stores the active page dims on doc.page.
expect(doc.page.width).toBeCloseTo(841.89, 1);
expect(doc.page.height).toBeCloseTo(595.28, 1);
});
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
const ended = new Promise((resolve) => doc.on('end', resolve));
doc.text('hello', 40, 40);
doc.end();
await ended;
const buf = Buffer.concat(chunks);
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: null },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('falls back to Helvetica when the custom TTF path does not exist', () => {
// No exception, no logger.error blow-up — just silent fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('registers a bundled font family when pdfFontFamily is set', () => {
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
// backend/assets/fonts/Inter/, so the resolver should pick 400
// for body and 700 for bold.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'Inter' },
});
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'NotARealFamily' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('strips path-traversal characters from pdfFontFamily', () => {
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: '../../etc/passwd' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
// The explicit upload is the priority-1 override. When the upload
// path is unusable (file missing) the family is consulted next.
// Here we set BOTH to invalid values and confirm Helvetica fallback
// — what matters is that the family DIDN'T get registered while a
// (failed) explicit path was being evaluated.
const { fonts } = pdfService.createBaseDocument({
issuer: {
pdfFontTtfPath: '/nonexistent/path/font.ttf',
pdfFontFamily: 'Inter',
},
});
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
// registers successfully. crm-body / crm-bold confirm a custom
// font won.
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('forwards PDF info metadata (Title, Author) to the document', () => {
const { doc } = pdfService.createBaseDocument({
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
});
// PDFKit copies these onto doc.info during construction.
expect(doc.info.Title).toBe('Tax Report 2026');
expect(doc.info.Author).toBe('picpeak');
});
});
describe('exported letterhead helper', () => {
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
expect(typeof pdfService.drawIssuerBlock).toBe('function');
});
});
// Storno rendering — smoke-tests that exercise the kind='storno'
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
// directly for German strings because PDFKit Flate-compresses
// content streams, but we CAN verify the renderer:
// - completes without throwing on a Storno-shaped context,
// - produces a valid %PDF magic header,
// - produces a SMALLER document than its invoice counterpart
// (no payment block, no QR slip → fewer bytes), proving the
// suppression branches actually fire.
//
// Visual correctness (title swap, reference line, signed totals) is
// validated by manual review of a real Storno PDF; the renderer's
// branch logic is unit-tested in service tests where the inputs
// can be asserted directly.
describe('renderInvoiceToBuffer — Storno branch', () => {
function buildContext(overrides = {}) {
return {
locale: 'de',
currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
shippingAmountMinor: 0, totalAmountMinor: 32310,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
// Bank + payment term are part of the baseline invoice so the
// payment block renders a real IBAN + Zahlungsbedingungen
// section. The Storno branch suppresses this entirely, which
// produces a visible byte-size delta.
bank: {
accountHolder: 'AcmeCo',
iban: 'CH9300762011623852957',
bic: 'POFICHBE',
currency: 'CHF',
},
qrFormat: 'none',
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
...overrides,
};
}
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
// Baseline: normal invoice with a payment block.
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
// Storno: same context but kind='storno' → payment block + QR
// both suppressed. Payment block alone is ~80pt tall in the
// PDF; its absence is reliably detectable as a byte-size delta.
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(stornoBuf.length).toBeGreaterThan(0);
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
@@ -0,0 +1,95 @@
/**
* Pure-function tests for the PDF rendering helpers. These are the
* functions that DON'T touch PDFKit / DB — formatting, salutation
* routing, EPC payload construction.
*
* The helpers aren't directly exported from pdfService.js (it
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
* them via `_internal` which the module already exposes for tests.
*/
const pdfService = require('../../src/services/pdfService');
const { formatMinor, formatDate, t } = pdfService._internal;
describe('formatMinor', () => {
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
// de-CH uses (U+2019) as the thousands separator.
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[',\u2019]?234\.56/);
});
it('formats large amounts with thousands separators', () => {
// 12345600 minor units = 123,456.00 major; the separator
// varies by locale (de-CH = U+2019, en-GB = ',').
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[',\u2019]456\.00/);
});
it('returns 0,00 for zero or null', () => {
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
});
it('returns 2-decimal output regardless of locale', () => {
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
});
});
describe('formatDate', () => {
// formatDate now respects ctx.dateFormat (object with `format`
// key) — when omitted defaults to DD.MM.YYYY.
it('defaults to DD.MM.YYYY when no format passed', () => {
expect(formatDate('2026-04-19')).toBe('19.04.2026');
});
it('honors the configured DD/MM/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
});
it('honors the configured MM/DD/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
});
it('honors ISO YYYY-MM-DD', () => {
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
});
it('returns empty string on empty input', () => {
expect(formatDate('')).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
});
it('returns empty string on invalid input rather than throwing', () => {
expect(formatDate('not-a-date')).toBe('');
});
it('accepts Date objects', () => {
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
});
});
describe('t (i18n lookup)', () => {
it('returns the EN value for an EN-only locale', () => {
expect(t('en', 'invoice_title')).toBe('Invoice');
expect(t('en', 'quote_title')).toBe('Quote');
});
it('returns the DE value for de locale', () => {
expect(t('de', 'invoice_title')).toBe('Rechnung');
expect(t('de', 'quote_title')).toBe('Angebot');
});
it('falls back to EN for unknown locales', () => {
expect(t('xx', 'invoice_title')).toBe('Invoice');
});
it('substitutes named tokens like {percent}', () => {
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
expect(out).toMatch(/3% discount if paid within 5 working days\./);
});
it('falls back to EN when the key is missing on the requested locale', () => {
// page_of is seeded on all locales — pick something that
// exists on EN with a substitution.
const out = t('zz', 'page_of', { current: 1, total: 3 });
expect(out).toBe('Page 1 of 3');
});
});
@@ -0,0 +1,217 @@
/**
* Tests for the migration-119 hierarchy support in quoteService:
* computeTotals, validateLineItemHierarchy, and the two-phase
* insertLineItemsHierarchical helper. All pure / db-mocked so the
* suite runs fast and is deterministic.
*/
const quoteService = require('../../src/services/quoteService');
const {
computeTotals,
validateLineItemHierarchy,
insertLineItemsHierarchical,
} = quoteService._internal;
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
const items = [
// Parent with its own price €500 — should be IGNORED because
// sub-items have prices. Parent's effective line_total becomes
// sum of priced sub-items.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
// Priced sub-items €150 + €200 = €350
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
// Another top-level item: €100
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
];
const out = computeTotals(items, 0, 0);
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
// Parent's own €500 is silently overridden.
expect(out.netAmountMinor).toBe(45000);
// Parent's stored line_total_minor reflects the resolved sum.
expect(out.lineItems[0].line_total_minor).toBe(35000);
});
it('priceless sub-items leave the parent\'s own line_total intact', () => {
const items = [
// Parent €500 with three priceless transparency-bullets — the
// €500 stands.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
expect(out.netAmountMinor).toBe(50000);
expect(out.lineItems[0].line_total_minor).toBe(50000);
});
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
const items = [
// Parent €500 → overridden because at least one sub-item is priced.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
// Priceless bullet — doesn't add anything
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
// Parent resolves to €150 (only priced sub-item).
expect(out.netAmountMinor).toBe(15000);
expect(out.lineItems[0].line_total_minor).toBe(15000);
});
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
], 0);
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
});
it('applies VAT to the resolved parent total', () => {
const out = computeTotals([
// Parent €1000 overridden by priced €800 sub-item
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
], 7.7);
// Resolved net = 80000, VAT 7.7% = 6160.
expect(out.netAmountMinor).toBe(80000);
expect(out.vatAmountMinor).toBe(6160);
expect(out.totalAmountMinor).toBe(86160);
});
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
], 0);
expect(out.netAmountMinor).toBe(100000);
});
});
describe('validateLineItemHierarchy', () => {
it('accepts a flat list of top-level items', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2 },
{ position: 3 },
])).not.toThrow();
});
it('accepts one level of sub-items under valid parents', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 1 },
{ position: 4 },
{ position: 5, parent_position: 4 },
])).not.toThrow();
});
it('rejects duplicate positions', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 1 },
])).toThrow(/Duplicate line item position/);
});
it('rejects a sub-item pointing at a missing parent', () => {
expect(() => validateLineItemHierarchy([
{ position: 1, parent_position: 99 },
])).toThrow(/missing parent position/);
});
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 2 },
])).toThrow(/max one level deep/);
});
it('rejects an item whose parent is itself', () => {
expect(() => validateLineItemHierarchy([
{ position: 5, parent_position: 5 },
])).toThrow(/cannot be its own parent/);
});
it('rejects an item without a positive position', () => {
expect(() => validateLineItemHierarchy([
{ position: 0 },
])).toThrow(/positive position/);
});
it('is a no-op on empty / non-array input', () => {
expect(() => validateLineItemHierarchy([])).not.toThrow();
expect(() => validateLineItemHierarchy(null)).not.toThrow();
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
});
});
describe('insertLineItemsHierarchical', () => {
// Tiny trx mock — captures insert calls so we can verify the
// two-phase ordering and the parent-id remap. `.returning('id')`
// returns a synthesised id matching the call order.
function makeTrxMock() {
let nextId = 100;
const inserts = []; // [{ table, row }]
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); }, // bare await: no returning() call
};
},
});
return { trx, inserts };
}
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
]);
// 4 inserts, all into quote_line_items.
expect(inserts).toHaveLength(4);
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
// Top-level items have parent_line_item_id = null.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBeNull();
// Sub-items reference the id returned for position-1 parent (100).
expect(inserts[2].row.parent_line_item_id).toBe(100);
expect(inserts[3].row.parent_line_item_id).toBe(100);
// parent_position is stripped (wire-only field, not a DB column).
expect(inserts[0].row).not.toHaveProperty('parent_position');
expect(inserts[2].row).not.toHaveProperty('parent_position');
});
it('copies details_text through to the inserted row', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
]);
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
});
it('is a no-op on empty items array', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
expect(inserts).toHaveLength(0);
});
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
]);
expect(inserts[0].table).toBe('invoice_line_items');
expect(inserts[0].row.invoice_id).toBe(42);
expect(inserts[0].row).not.toHaveProperty('quote_id');
});
});
@@ -0,0 +1,179 @@
/**
* Tests for quoteService lock + state-transition guards:
* - updateQuote refuses on accepted / declined / converted
* - adminAcceptQuote refuses on already-terminal states + atomic
* update path
*
* db deep-mocked, same chain pattern as invoiceService tests.
*/
const tableChains = {};
function makeChain() {
return {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
// knex chains are thenable; mirror that so `await trx('t')...`
// resolves to an array of rows.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._selectResult); }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
leftJoin: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
};
}
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const quoteService = require('../../src/services/quoteService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('quoteService.updateQuote — lock guards', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.updateQuote(99, {}, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('locks accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
};
// The lock check sits at the TOP of updateQuote. The
// observable behavior we care about is "no QUOTE_LOCKED
// 409 thrown on these statuses". The full transaction
// path may resolve to anything (incl. undefined) since
// the test mocks the trx callback — that's fine.
let err = null;
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
catch (e) { err = e; }
if (err) {
// Any error other than the QUOTE_LOCKED guard is allowed
// (we're not exercising the full path here).
expect(err.code).not.toBe('QUOTE_LOCKED');
}
resetChains();
}
});
});
describe('quoteService.adminAcceptQuote', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.adminAcceptQuote(99, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('refuses already-accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
});
it('refuses declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
});
it('refuses converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
});
it('accepts draft / sent / expired and returns lockedAt', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, customer_account_id: 5,
currency: 'CHF', language: 'de',
quote_number: 'Q-2026-0001',
total_amount_minor: 10000,
event_name: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, email: '[email protected]', display_name: 'Test',
};
pickChainFor('quote_line_items')._selectResult = [];
pickChainFor('business_profile')._firstValue = null;
const result = await quoteService.adminAcceptQuote(1, 42);
expect(result.status).toBe('accepted');
expect(result.lockedAt).toBeInstanceOf(Date);
resetChains();
}
});
});
@@ -0,0 +1,251 @@
/**
* Smoke tests for taxReportService.renderTaxReportPdf and
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
* businessProfileService (canned issuer) and assert that the
* rendered output meets a few hard requirements:
*
* - PDF starts with the %PDF magic bytes, is non-empty
* - CSV header contains the localised column names
* - CSV body contains the invoice numbers in order
* - CSV totals row contains the grand totals
*/
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
return {
_rows: initialRows,
then(onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
}
const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; everything else
// (main listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: {
company_name: 'ACME Test GmbH',
address_line1: 'Teststrasse 1',
postal_code: '8000',
city: 'Zürich',
country_code: 'CH',
email: '[email protected]',
default_locale: 'de',
default_currency: 'CHF',
pdf_show_logo: 1,
pdf_show_company_name: 1,
pdf_logo_height: 56,
pdf_company_name_inline: 0,
pdf_folding_marks: 'none',
logo_path: null,
pdf_font_ttf_path: null,
},
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
}));
const taxReportService = require('../../src/services/taxReportService');
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
const SAMPLE_ROW = (override = {}) => ({
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
customer_last_name: null, customer_display_name: null, customer_email: null,
event_name: 'Hochzeit Müller',
...override,
});
describe('renderTaxReportPdf', () => {
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders a header even when no invoices are in the period', async () => {
invoiceRowsForRun = [];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders successfully when cancelled rows are present', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
];
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders without throwing when a row has long text that must wrap', async () => {
// Customer + event labels long enough to force multi-line wrap
// in their narrow columns. The dynamic row-height logic should
// grow the row to fit rather than overlapping the next one.
invoiceRowsForRun = [
SAMPLE_ROW({
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
}),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('honours the locale parameter (en) without throwing', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// Regression: the page-number footer used to place its baseline
// inside the bottom margin, which made PDFKit auto-paginate one
// empty page per existing page (so a 1-page report ended up as 2,
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
// is the cheapest way to detect a recurrence without parsing the
// PDF — every page object in the xref table carries that marker
// exactly once.
it('does not duplicate pages when stamping the page-number footer', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
// Small single-row report should fit on a single page. The
// previous buggy renderer produced 2 (1 content + 1 footer-only).
expect(pageMarkers.length).toBe(1);
});
});
describe('renderTaxReportCsv', () => {
it('returns a CSV blob with the de localised header row', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n');
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice
expect(lines[0]).toContain('Kunde');
expect(lines[0]).toContain('Netto');
});
it('lists each invoice on its own row in order', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
const idxA = content.indexOf('R-2026-0001');
const idxB = content.indexOf('R-2026-0002');
const idxC = content.indexOf('R-2026-0003');
expect(idxA).toBeGreaterThan(0);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
});
it('appends a trailing totals row with the grand totals', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
}),
SAMPLE_ROW({
id: 2, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
}),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
expect(content).toMatch(/"150\.00"/);
expect(content).toMatch(/"11\.55"/);
expect(content).toMatch(/"161\.55"/);
});
it('marks cancelled rows with a 1 in the cancelled column', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Migration 126 added a trailing Skonto column. The cancelled
// marker is now second-to-last; the Skonto cell is empty for
// non-Skonto rows. Asserting on a regex keeps the test stable
// against future trailing-column additions.
const dataRow = content.split('\r\n')[1];
expect(/"1","[^"]*"$/.test(dataRow)).toBe(true);
});
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(content).toContain('\r\n');
// The route wraps the BOM around the content; the service output
// itself is BOM-free so callers (tests) get a clean string.
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
});
});
@@ -0,0 +1,338 @@
/**
* Tests for taxReportService.
*
* Two layers:
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
* buildCustomerLabel) — no db mock needed.
* 2. getTaxReport — db chain deep-mocked so we can feed canned
* invoice rows and assert the filter/bucket/total math.
*/
// ----- mock db chain ---------------------------------------------------
//
// taxReportService builds a single chain:
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
// .where(...).whereIn(...).orderBy(...).select(...)
// and then for cancelled ids:
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
//
// We use one shared chain factory that returns canned rows from
// `_selectResult` for the main query, and lets us swap the result
// for the replacements lookup via a "second-call" hook.
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
const c = {
_rows: initialRows,
then: function (onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
return c;
}
const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]);
// First call: main listing. Second call: replacements lookup.
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments and returns the mocked rows, so the raw() return value
// just needs to exist — a string is fine.
mockDbFn.raw = jest.fn((sql) => sql);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
const taxReportService = require('../../src/services/taxReportService');
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
// ----- pure helpers ----------------------------------------------------
describe('grossUpLateFee', () => {
it('returns zeros for a zero or negative fee', () => {
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
});
it('returns the whole fee as net when VAT rate is 0', () => {
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
// Missing/invalid rate is treated the same.
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
});
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
});
it('guarantees net + vat === gross input (no rounding drift)', () => {
for (const fee of [1, 2500, 9999, 12345, 250000]) {
for (const rate of [7.7, 8.1, 19, 20.5]) {
const { net, vat } = grossUpLateFee(fee, rate);
expect(net + vat).toBe(fee);
}
}
});
});
describe('computeReportedAmounts', () => {
it('returns stored amounts unchanged when late fee is zero', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 10770,
late_fee_amount_minor: 0,
vat_rate: 7.7,
});
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
});
it('adds the late-fee net/vat split onto the stored net + vat', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
late_fee_amount_minor: 2500,
vat_rate: 7.7,
});
expect(r.netMinor).toBe(10000 + 2321);
expect(r.vatMinor).toBe(770 + 179);
expect(r.totalMinor).toBe(13270);
});
it('keeps total at the stored total even when late fee is present', () => {
// The stored total already includes the late fee — we never
// recompute it from net + vat in the report.
const r = computeReportedAmounts({
net_amount_minor: 50000,
vat_amount_minor: 4050,
total_amount_minor: 56550,
late_fee_amount_minor: 2500,
vat_rate: 8.1,
});
expect(r.totalMinor).toBe(56550);
});
});
describe('buildCustomerLabel', () => {
it('prefers company_name when present', () => {
expect(buildCustomerLabel({
customer_company_name: 'ACME GmbH',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
customer_email: '[email protected]',
})).toBe('ACME GmbH');
});
it('falls back to first + last name', () => {
expect(buildCustomerLabel({
customer_company_name: '',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
})).toBe('Anna Beispiel');
});
it('falls back to display_name when no name parts', () => {
expect(buildCustomerLabel({
customer_display_name: 'Anna B.',
})).toBe('Anna B.');
});
it('falls back to email as a last resort', () => {
expect(buildCustomerLabel({ customer_email: '[email protected]' })).toBe('[email protected]');
});
it('returns empty string when nothing usable is present', () => {
expect(buildCustomerLabel({})).toBe('');
});
});
// ----- getTaxReport ----------------------------------------------------
describe('getTaxReport', () => {
it('throws when from/to or currency are missing', async () => {
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
.rejects.toThrow(/currency/);
});
it('returns rows + totals for a clean period with one paid invoice', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
});
expect(out.currency).toBe('CHF');
expect(out.rows).toHaveLength(1);
expect(out.rows[0]).toMatchObject({
invoiceNumber: 'R-2026-0001',
isCancelled: false,
customerLabel: 'ACME GmbH',
eventName: 'Wedding A',
netMinor: 10000,
vatMinor: 770,
totalMinor: 10770,
});
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(0);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
});
it('keeps cancelled rows visible but excludes them from totals', async () => {
invoiceRowsForRun = [
{
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
{
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 10,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
// The supersedes lookup query: row 11 supersedes row 10.
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toHaveLength(2);
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
expect(cancelled.isCancelled).toBe(true);
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
expect(replacement.isCancelled).toBe(false);
// Totals: only the replacement counts.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(1);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'A', event_name: 'X',
},
{
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'B', event_name: 'Y',
},
{
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
currency: 'CHF', status: 'sent', vat_rate: 8.1,
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'C', event_name: 'Z',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.totalsByVatRate).toHaveLength(2);
// Sorted ascending by rate.
expect(out.totalsByVatRate[0]).toEqual({
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
});
expect(out.totalsByVatRate[1]).toEqual({
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
});
expect(out.grandTotalNet).toBe(35000);
expect(out.grandTotalVat).toBe(2795);
expect(out.grandTotal).toBe(37795);
});
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
late_fee_amount_minor: 2500, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
expect(out.rows[0].netMinor).toBe(12321);
expect(out.rows[0].vatMinor).toBe(949);
expect(out.rows[0].totalMinor).toBe(13270);
// Grand totals reflect the same gross-up math.
expect(out.grandTotalNet).toBe(12321);
expect(out.grandTotalVat).toBe(949);
expect(out.grandTotal).toBe(13270);
});
it('returns empty rows + zero totals when no invoices match the period', async () => {
invoiceRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toEqual([]);
expect(out.grandTotalNet).toBe(0);
expect(out.grandTotalVat).toBe(0);
expect(out.grandTotal).toBe(0);
expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Tests for the ISO 13616 IBAN validator.
*
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
* — they are publicly published sample values used by every IBAN
* implementation as test vectors. NOT real account numbers.
*/
const { validateIban, _internal } = require('../../src/utils/iban');
describe('validateIban', () => {
it('accepts a canonical Swiss IBAN', () => {
const out = validateIban('CH9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
expect(out.reason).toBeUndefined();
});
it('accepts a Liechtenstein IBAN', () => {
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
});
it('accepts a German IBAN', () => {
expect(validateIban('DE89370400440532013000').valid).toBe(true);
});
it('accepts an Austrian IBAN', () => {
expect(validateIban('AT611904300234573201').valid).toBe(true);
});
it('accepts a British IBAN with alphanumeric BBAN', () => {
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
});
it('normalises spaces and lowercase input', () => {
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('normalises mixed-case input', () => {
const out = validateIban('ch9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('rejects an empty / null / undefined value', () => {
expect(validateIban('').reason).toBe('EMPTY');
expect(validateIban(' ').reason).toBe('EMPTY');
expect(validateIban(null).reason).toBe('EMPTY');
expect(validateIban(undefined).reason).toBe('EMPTY');
});
it('rejects a malformed string (numbers in the country slot)', () => {
const out = validateIban('12930076201162385295');
expect(out.valid).toBe(false);
expect(out.reason).toBe('FORMAT');
});
it('rejects a too-short string', () => {
expect(validateIban('CH93').reason).toBe('FORMAT');
});
it('rejects a too-long string (over 34 chars)', () => {
// 35 chars: pads beyond the ISO 13616 max
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
});
it('rejects a known-country IBAN with the wrong length', () => {
// CH must be 21 chars; this one is 22.
const out = validateIban('CH9300762011623852957X');
expect(out.valid).toBe(false);
expect(out.reason).toBe('LENGTH');
});
it('rejects an IBAN with a broken checksum', () => {
// Same shape, last digit altered.
const out = validateIban('CH9300762011623852950');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM');
});
it('rejects an IBAN with internally invalid characters', () => {
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
});
it('accepts an unknown-country IBAN that meets the generic length range', () => {
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
// structural regex passes if length is in [15, 34] and the
// checksum holds. Build a checksum-valid string:
//
// Format: ZZ + check + BBAN. We don't have a real ZZ template
// so this test just confirms unknown country codes route
// through the fallback length check rather than failing on
// LENGTH outright. A checksum-failing ZZ value will hit
// CHECKSUM, not LENGTH, which is the assertion below.
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
});
});
describe('mod97', () => {
it('returns 1 for the canonical CH test vector', () => {
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
});
it('returns something other than 1 for a tampered IBAN', () => {
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
});
});
describe('IBAN_LENGTHS table', () => {
it('has the expected lengths for the most common European countries', () => {
// Sanity check that the table didn't drift if someone edits it.
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Pure-function tests for the PDF filename builder used on every
* quote / invoice download endpoint + the PDF's internal Title
* metadata. No mocks needed — all behavior is deterministic.
*/
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
describe('sanitiseSegment', () => {
it('returns empty string for null/undefined', () => {
expect(sanitiseSegment(null)).toBe('');
expect(sanitiseSegment(undefined)).toBe('');
expect(sanitiseSegment('')).toBe('');
});
it('replaces filesystem-hostile characters with "-"', () => {
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
});
it('collapses spaces into single "-"', () => {
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
});
it('collapses repeat dashes', () => {
expect(sanitiseSegment('a-----b')).toBe('a-b');
});
it('trims leading + trailing dashes/dots', () => {
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
});
it('preserves non-ASCII letters', () => {
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
});
it('caps length at 80 chars by default', () => {
const long = 'a'.repeat(120);
expect(sanitiseSegment(long)).toHaveLength(80);
});
it('honors custom maxLen', () => {
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
});
});
describe('customerLabel', () => {
it('prefers company_name over person name', () => {
expect(customerLabel({
company_name: 'ACME GmbH',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('ACME-GmbH');
});
it('falls back to first + last when company_name is empty', () => {
expect(customerLabel({
company_name: '',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('Luca-Bresch');
});
it('falls back to display_name when no company + no person', () => {
expect(customerLabel({
display_name: 'Luca B.',
})).toBe('Luca-B');
});
it('falls back to email local-part as a last resort', () => {
expect(customerLabel({
email: '[email protected]',
})).toBe('luca');
});
it('uses "customer" when everything is missing', () => {
expect(customerLabel({})).toBe('customer');
expect(customerLabel(null)).toBe('customer');
});
it('trims whitespace before evaluating truthiness', () => {
// company_name = " " should NOT trigger the company branch.
expect(customerLabel({
company_name: ' ',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('Luca-Bresch');
});
});
describe('buildPdfFilename', () => {
const customer = { company_name: 'ACME GmbH' };
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer,
})).toBe('R-2026-0001_ACME-GmbH.pdf');
});
it('falls back to the fallback when docNumber is null (preview)', () => {
expect(buildPdfFilename({
docNumber: null,
customer,
fallback: 'invoice-preview',
})).toBe('invoice-preview_ACME-GmbH.pdf');
});
it('uses "document" when both docNumber + fallback are absent', () => {
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
});
it('sanitises the customer half too', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer: { company_name: 'Bad/Name:Inc.' },
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
});
it('always ends with .pdf', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer: {},
})).toMatch(/\.pdf$/);
});
});
@@ -0,0 +1,108 @@
/**
* resolveLogoFile — verifies the path-priority chain + the
* unsupported-format guard. fs + appSettings + storage config are
* mocked so the test is fully deterministic.
*/
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/config/storage', () => ({
getStoragePath: jest.fn(() => '/app/storage'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const fs = require('fs');
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
const { getAppSetting } = require('../../src/utils/appSettings');
describe('resolveLogoFile', () => {
let existsSpy, statSpy;
beforeEach(() => {
existsSpy = jest.spyOn(fs, 'existsSync');
statSpy = jest.spyOn(fs, 'statSync');
existsSpy.mockReturnValue(false);
statSpy.mockImplementation(() => ({ isFile: () => true }));
getAppSetting.mockReset();
});
afterEach(() => {
existsSpy.mockRestore();
statSpy.mockRestore();
});
it('returns null when no sources are configured', async () => {
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({});
expect(out).toBeNull();
});
it('prefers business_profile.logo_path over branding fallbacks', async () => {
// The profile path exists, branding doesn't.
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
const out = await resolveLogoFile({
logo_path: 'uploads/logos/profile.png',
});
expect(out).toBe('/app/storage/uploads/logos/profile.png');
});
it('falls back to branding_logo_path when profile is empty', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
getAppSetting.mockImplementation(async (key) => {
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
return null;
});
const out = await resolveLogoFile({ logo_path: '' });
expect(out).toBe('/app/storage/uploads/logos/branding.png');
});
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
getAppSetting.mockImplementation(async (key) => {
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
return null;
});
const out = await resolveLogoFile({});
expect(out).toBe('/app/storage/uploads/logos/branding.png');
});
it('skips SVG (PDFKit cannot embed)', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
expect(out).toBeNull();
});
it('also rejects WebP / GIF / TIFF', async () => {
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
existsSpy.mockReturnValue(true);
statSpy.mockImplementation(() => ({ isFile: () => true }));
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
expect(out).toBeNull();
}
});
it('accepts PNG and JPEG', async () => {
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
}
});
it('treats absolute paths as-is when they exist', async () => {
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
expect(out).toBe('/abs/path/logo.png');
});
});
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.
File diff suppressed because it is too large Load Diff
+420 -30
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.42.2-beta.0",
"version": "3.47.2-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.42.2-beta.0",
"version": "3.47.2-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -38,13 +38,17 @@
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -308,7 +312,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
@@ -1037,7 +1040,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2705,6 +2707,24 @@
"@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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -3548,6 +3568,15 @@
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz",
@@ -3740,7 +3769,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4263,6 +4291,24 @@
"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": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -4283,7 +4329,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4484,7 +4529,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4651,6 +4695,15 @@
"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": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4946,6 +4999,12 @@
"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": {
"version": "4.4.3",
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -5084,6 +5152,12 @@
"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": {
"version": "29.6.3",
"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_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": {
"version": "3.0.0",
"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.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5624,7 +5703,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -5767,7 +5845,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -6228,7 +6322,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -6577,7 +6670,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -6608,19 +6700,6 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -7607,6 +7686,13 @@
"@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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7910,6 +7996,25 @@
"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": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9054,7 +9159,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -9066,6 +9170,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9117,7 +9227,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -9178,6 +9287,37 @@
"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": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@@ -9371,6 +9511,23 @@
"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": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@@ -9618,6 +9775,161 @@
],
"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": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -9782,12 +10094,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"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": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -9851,6 +10168,12 @@
"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": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@@ -10030,8 +10353,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
@@ -10750,6 +11072,12 @@
"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": {
"version": "6.2.8",
"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"
}
},
"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": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
@@ -10941,6 +11293,12 @@
"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": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11103,6 +11461,32 @@
"dev": true,
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
@@ -11284,6 +11668,12 @@
"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": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+4
View File
@@ -44,13 +44,17 @@
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
+59 -4
View File
@@ -20,6 +20,7 @@ const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
@@ -46,9 +47,28 @@ const secureImagesRoutes = require('./src/routes/secureImages');
const app = express();
const PORT = process.env.PORT || 3000;
// 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');
// Trust proxy headers (required for Traefik/nginx).
//
// `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
// 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');
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
// 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', 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/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
@@ -729,9 +770,23 @@ async function startServer() {
// Start expiration checker
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
await initializeTransporter();
// Seed CRM / contract / event-reminder email templates and recover
// any queue rows that exhausted retries because their template
// didn't exist yet. Runs once per boot via module-level caches in
// each seeder. See _emailTemplateBoot.js for the full rationale.
try {
const { seedEmailTemplatesAndRecoverQueue } = require('./src/services/_emailTemplateBoot');
await seedEmailTemplatesAndRecoverQueue(db, logger);
} catch (err) {
logger.warn('Email template self-heal failed at boot:', err.message);
}
startEmailQueueProcessor();
// Start webhook delivery worker (#327)
@@ -66,7 +66,11 @@ beforeEach(() => {
describe('createInvitation', () => {
it('rejects when a customer with the email already exists', async () => {
const svc = require('../services/customerAccountsService');
db.mockImplementationOnce(() => chain({ first: { id: 1, email: '[email protected]' } }));
// Service only rejects when the existing row has a password_hash —
// a passive (password_hash = null) row is the "promote to portal"
// path and is allowed through. Pin the rejection contract by mocking
// an active row.
db.mockImplementationOnce(() => chain({ first: { id: 1, email: '[email protected]', password_hash: 'hash' } }));
await expect(
svc.createInvitation({ email: '[email protected]', invitedById: 9 })
).rejects.toThrow(/already exists/i);
@@ -29,6 +29,14 @@ jest.mock('../services/storage', () => ({
getStorage: jest.fn(),
}));
// dateFormatter.formatDate queries `app_settings` for general_date_format,
// which would add a third unmocked db() call to every buildOgMetadata path.
// The format itself is irrelevant to the cover-vs-logo contract this file
// pins — short-circuit it to a stable string so the tests stay focused.
jest.mock('../utils/dateFormatter', () => ({
formatDate: jest.fn().mockResolvedValue('12.06.2026'),
}));
const { db } = require('../database/db');
const { ensureThumbnail } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
+516
View File
@@ -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;
+312
View File
@@ -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;
+610
View File
@@ -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;
+335 -10
View File
@@ -12,6 +12,8 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService');
const customerHoursService = require('../services/customerHoursService');
const invoiceService = require('../services/invoiceService');
const router = express.Router();
@@ -38,14 +40,32 @@ function transformCustomer(c) {
city: c.city,
state: c.state,
countryCode: c.country_code,
countryName: c.country_name,
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,
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
// frontend doesn't have to deal with SQLite's 0/1 values.
featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
featureQuotes: c.feature_quotes === true || c.feature_quotes === 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,
createdAt: c.created_at,
updatedAt: c.updated_at,
@@ -141,6 +161,11 @@ router.post('/invite', [
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 }),
// 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) => {
validateRequest(req);
const invitation = await customerAccountsService.createInvitation({
@@ -162,7 +187,15 @@ router.post('/invite', [
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;
}
successResponse(res, payload, 201);
@@ -181,6 +214,114 @@ router.delete('/invitations/:id', [
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 ----------------------------------------------------
router.get('/:id', [
@@ -197,15 +338,24 @@ router.get('/:id', [
router.put('/:id', [
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 }),
body('email').optional().isEmail().normalizeEmail(),
body('salutation').optional().isString().isLength({ max: 32 }),
body('first_name').optional().isString().isLength({ max: 80 }),
body('last_name').optional().isString().isLength({ max: 80 }),
body('display_name').optional().isString().isLength({ max: 120 }),
body('phone').optional().isString().isLength({ max: 40 }),
body('company_name').optional().isString().isLength({ max: 120 }),
// `{ nullable: true }` so a passive customer who has no salutation /
// phone / company in their record can still save the page — the
// form sends `null` for those empty fields, and plain `.optional()`
// (which only skips `undefined`) would reject null at the
// subsequent `.isString()` step. Mirrors the existing pattern on
// 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('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
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('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
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('is_active').optional().isBoolean(),
body('feature_calendar').optional().isBoolean(),
body('feature_quotes').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) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
@@ -327,7 +489,11 @@ router.post('/:id/password-reset', [
*/
router.put('/:id/events', [
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 }),
body('event_ids').isArray(),
body('event_ids.*').isInt({ min: 1 }),
@@ -341,4 +507,163 @@ router.put('/:id/events', [
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;
+172
View File
@@ -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;
+83
View File
@@ -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 3236 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;
+455
View File
@@ -0,0 +1,455 @@
/**
* 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',
// Pre-event reminder emails (migration 143). The runtime resolver
// picks `event_reminder_<events.event_type>` with fallback to
// `event_reminder_default`. The dev tester exposes every seeded
// template so the maintainer can eyeball each category's body
// without staging a real event for each.
'event_reminder_default',
'event_reminder_wedding',
'event_reminder_birthday',
'event_reminder_corporate',
'event_reminder_other',
];
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);
// Pre-event reminder mock: pretend the event is 2 days out (the
// global default for `crm_event_reminders_days_before`). Renders
// {{event_date}} + {{days_before}} for the event_reminder_*
// templates.
const eventDate = new Date(today.getTime() + 2 * 86400000);
const businessProfile = await db('business_profile').first().catch(() => null);
const common = {
customer_name: 'Sample Customer',
customer_email: '[email protected]',
event_name: 'Sample Event',
event_date: fakeShortDate(eventDate),
days_before: 2,
business_name: businessProfile?.legal_name || 'Sample Studio',
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;
+98
View File
@@ -303,6 +303,16 @@ async function getTemplateTranslations(templateId, template) {
// Get email templates
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
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')
.select('*')
.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
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
+69
View File
@@ -20,6 +20,8 @@ const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { normaliseEventTimeTriple } = require('../services/eventService');
const { hasColumnCached } = require('../utils/schemaCache');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
@@ -334,6 +336,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}),
body('event_name').notEmpty().trim(),
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_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
@@ -425,6 +433,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_type,
event_name,
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,
password,
welcome_message = '',
@@ -634,12 +647,24 @@ router.post('/', adminAuth, requirePermission('events.create'), [
? protectionDefaults.enable_devtools_protection
: 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
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
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 } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
@@ -1102,12 +1127,30 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
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('is_active').optional().isBoolean(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
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_email').optional().isEmail().normalizeEmail(),
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.
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
logger.debug('Update event request', {
id,
+44 -2
View File
@@ -41,13 +41,38 @@ const KNOWN_FLAGS = [
// Customer-side portal surface (#354). Gates /customer/* routes
// and the Accounts sub-page under Clients. See migration 095.
'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
// new release that hasn't run its migration yet on this instance).
const DEFAULT_FLAGS = {
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,
calendarBooking: false,
quotes: false,
@@ -56,6 +81,9 @@ const DEFAULT_FLAGS = {
analytics: true,
userManagement: true,
clients: false,
taxReport: false,
hoursLogging: false,
contracts: false,
};
async function readAllFlags() {
@@ -76,6 +104,10 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = 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
// toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
@@ -85,7 +117,17 @@ function applyDependencyRules(flags) {
// ever drifts (e.g. partial migration run).
out.clients = Boolean(
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;
}
+896
View File
@@ -0,0 +1,896 @@
/**
* 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 }),
// Negative unit prices are allowed so admins can add manual
// discount / Rabatt lines (e.g. "Treuerabatt -50,00 €"). The
// service-layer total guard rejects invoices whose net goes below
// zero — for credit notes, use Storno instead.
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt(),
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;
// Negative line items (Rabatt) are allowed, but the resulting
// invoice total must not go below zero. Credit notes belong in
// the Storno path, not in regular invoice edits.
if (updates.total_amount_minor < 0) {
return res.status(400).json({
error: 'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.',
code: 'INVOICE_TOTAL_NEGATIVE',
});
}
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;
+824
View File
@@ -0,0 +1,824 @@
/**
* 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 }),
// Negative unit prices are allowed so admins can add manual
// discount / Rabatt lines (e.g. "Treuerabatt -50,00 €"). The
// service-layer total guard rejects quotes whose net goes below
// zero — see quoteService.
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt(),
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;
+16 -3
View File
@@ -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) => {
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
const settingsObject = {};
settings.forEach(setting => {
+123
View File
@@ -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;
+342
View File
@@ -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;
+342
View File
@@ -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;
+101
View File
@@ -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;
+184
View File
@@ -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;
+13
View File
@@ -105,6 +105,19 @@ router.get('/', async (req, res) => {
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
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',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
@@ -0,0 +1,68 @@
/**
* Recipient resolution for billing-class outbound emails (invoice,
* Storno, payment reminders).
*
* Customer accounts carry two email fields:
* - `email` primary contact, used for account auth,
* gallery sharing, quote / contract sends, and
* event reminders. The decision-maker address.
* - `billing_email` optional bookkeeper / accounts-payable
* address. When set, billing documents go To:
* here and the primary `email` is CC'd so the
* decision-maker stays in the loop.
*
* Non-billing flows (quote, contract, gallery, event reminder, gallery
* share) must NOT use this helper they always route to the primary
* `email` regardless of whether a billing_email is configured.
*
* The per-document `cc_pdf_email` field on invoices / Storno / quotes
* is an additional CC the admin can set per save; this helper folds it
* in alongside the billing/primary split and dedupes against the To:
* address so the same address never appears twice on one envelope.
*/
/**
* @param {object} customer a `customer_accounts` row; only
* `email` + `billing_email` are read.
* @param {string|null|undefined} perDocCcEmail the `cc_pdf_email`
* column from the document being sent;
* may be null/empty (no per-doc CC).
* @returns {{ to: string, cc: string[] | undefined }}
* `to` single address (billing_email when set, else email).
* `cc` array of additional addresses, or undefined when there
* are none. Always deduplicated against `to` and against
* itself (case-insensitive).
*/
function resolveBillingRecipients(customer, perDocCcEmail) {
const billing = String(customer?.billing_email || '').trim();
const main = String(customer?.email || '').trim();
const perDoc = String(perDocCcEmail || '').trim();
const to = billing || main;
if (!to) {
// No usable address at all. Caller will hit emailProcessor's own
// validation; we just return a safe shape so callers don't crash.
return { to: '', cc: undefined };
}
const toKey = to.toLowerCase();
const ccKeys = new Set();
const ccList = [];
const pushCc = (addr) => {
if (!addr) return;
const key = addr.toLowerCase();
if (key === toKey || ccKeys.has(key)) return;
ccKeys.add(key);
ccList.push(addr);
};
// Only CC the main email when billing_email actually took the To
// slot — when billing is empty, `to` already IS the main email and
// we don't want to CC self.
if (billing && main) pushCc(main);
if (perDoc) pushCc(perDoc);
return { to, cc: ccList.length > 0 ? ccList : undefined };
}
module.exports = { resolveBillingRecipients };
@@ -0,0 +1,97 @@
/**
* Boot-time wiring for the three self-heal seeders that own the
* CRM-era email templates (quotes / invoices / Storno / payment
* reminders / contract send + signed / event reminders).
*
* **Why this lives outside the individual services**
*
* The seeders themselves (`crmEmailTemplates.js`,
* `contractEmailTemplates.js`, `eventReminderTemplates.js`) are
* idempotent and module-cached, but they were never called at boot.
* contractEmailTemplates is called lazily by every contractService
* send; eventReminderTemplates by the admin email-templates list
* route. crmEmailTemplates was orphaned no caller anywhere so
* every install that didn't pre-exist its templates failed every
* quote_sent / invoice_sent / storno_issued send with
* `Email template '<key>' not found`. The queue processor retries 3
* times then leaves the row in `status='pending', retry_count=3`,
* silently dead with no admin surface exactly the failure flagged
* in [[feedback_observable_failure_state]] and [[feedback_self_heal_pattern]].
*
* Wiring all three into the boot path fixes new installs at first
* start AND retroactively fixes already-deployed installs whose
* queue is full of retry-exhausted rows: after we seed the missing
* template we reset retry_count on rows whose `email_type` matches
* a key we just inserted, so the queue processor's next tick picks
* them back up.
*
* Safe to call multiple times each underlying seeder short-
* circuits after its first successful pass via a module-level flag.
*/
const { ensureCrmEmailTemplatesSeeded } = require('./crmEmailTemplates');
const { ensureContractEmailTemplatesSeeded } = require('./contractEmailTemplates');
const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates');
/**
* Run all three template seeders, then recover any email_queue rows
* that exhausted their retries because the template they needed didn't
* exist yet.
*
* @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[], recovered: number }>}
* `seeded` flat list of template_keys newly inserted across all
* three seeders.
* `recovered` count of email_queue rows whose retry_count was
* reset to 0 because their template now exists.
*/
async function seedEmailTemplatesAndRecoverQueue(db, logger) {
const log = logger || { info: () => {}, warn: () => {} };
const seeded = [];
for (const seedFn of [
ensureCrmEmailTemplatesSeeded,
ensureContractEmailTemplatesSeeded,
ensureEventReminderTemplatesSeeded,
]) {
try {
const inserted = await seedFn(db, log);
if (Array.isArray(inserted) && inserted.length > 0) {
seeded.push(...inserted);
}
} catch (err) {
// Boot continues. A missing seed is annoying but not fatal —
// the lazy callers (where they exist) will retry; admin can
// re-trigger via the email-templates page. We just log loudly.
log.warn(`Email template self-heal failed for ${seedFn.name}: ${err.message}`);
}
}
if (seeded.length === 0) return { seeded, recovered: 0 };
// Recover stuck queue rows. The queue processor caps retries at 3
// (emailProcessor.processEmailQueue); rows past that are skipped
// forever. For every template we just inserted, find any pending
// rows of that email_type whose retries were exhausted and reset
// them so the processor picks them up on its next tick.
let recovered = 0;
if (await db.schema.hasTable('email_queue')) {
try {
recovered = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '>=', 3)
.whereIn('email_type', seeded)
.update({ retry_count: 0, error_message: null });
if (recovered > 0) {
log.info(`Recovered ${recovered} email_queue row(s) after self-healing templates: ${seeded.join(', ')}`);
}
} catch (err) {
log.warn(`email_queue recovery skipped after self-heal: ${err.message}`);
}
}
return { seeded, recovered };
}
module.exports = { seedEmailTemplatesAndRecoverQueue };
+172
View File
@@ -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,
};
@@ -0,0 +1,181 @@
/**
* Contract email template definitions, extracted from migration 130
* so both the migration AND a runtime seeder can read from the same
* source. The runtime seeder is needed because an admin who ran
* migration 130 BEFORE we added contract_fully_signed to it won't
* have that template in their email_templates table yet the
* dual-party send in contractService.recordAdminCountersignature
* depends on it. Per the maintainer's "never ship compensation
* migrations" rule, we self-heal at runtime instead.
*
* `ensureContractEmailTemplatesSeeded()` is idempotent call it as
* often as you like, only missing rows get inserted. Module-level
* boolean caches the "all templates verified" state so the check
* is free after the first call in a process.
*/
const CONTRACT_EMAIL_TEMPLATES = {
contract_sent: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_name', 'response_url', 'title', 'event_name', 'valid_until'],
en: {
subject: 'Contract {{contract_number}} ready for your signature',
body_html: `<h2>Contract {{contract_number}}</h2>
<p>Dear {{customer_name}},</p>
<p>Please find the contract {{contract_number}}{{#if title}} "{{title}}"{{/if}}{{#if event_name}} for "{{event_name}}"{{/if}} attached.</p>
<p>You can review and sign the contract directly in your browser via the link below:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{response_url}}" class="button">Review &amp; sign contract</a>
</p>
<p>Or open the full contract:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Please sign by {{valid_until}}.</p>{{/if}}`,
body_text: `Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}`,
},
de: {
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
body_html: `<h2>Vertrag {{contract_number}}</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie den Vertrag {{contract_number}}{{#if title}} {{title}}"{{/if}}{{#if event_name}} für „{{event_name}}"{{/if}}.</p>
<p>Sie können den Vertrag direkt online prüfen und unterzeichnen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{response_url}}" class="button">Vertrag prüfen &amp; unterzeichnen</a>
</p>
<p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Bitte unterzeichnen Sie bis {{valid_until}}.</p>{{/if}}`,
body_text: `Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}`,
},
},
contract_fully_signed: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_name', 'title'],
en: {
subject: 'Contract {{contract_number}} fully signed',
body_html: `<h2>Contract {{contract_number}} — fully signed</h2>
<p>Dear {{customer_name}},</p>
<p>Both parties have now signed contract {{contract_number}}{{#if title}} "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.</p>
<p style="font-size: 13px; color: #666;">This is the authoritative signed copy. Keep it alongside the related quote and invoices.</p>`,
body_text: `Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.`,
},
de: {
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
body_html: `<h2>Vertrag {{contract_number}} vollständig unterzeichnet</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>der Vertrag {{contract_number}}{{#if title}} {{title}}"{{/if}} wurde nun von beiden Parteien unterzeichnet. Im Anhang finden Sie das beidseitig unterzeichnete PDF für Ihre Unterlagen.</p>
<p style="font-size: 13px; color: #666;">Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.</p>`,
body_text: `Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.`,
},
},
contract_signed_admin_notification: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_email', 'signed_customer_name', 'admin_dashboard_url'],
en: {
subject: 'Contract {{contract_number}} signed by {{customer_email}}',
body_html: `<h2>Contract signed</h2><p>{{signed_customer_name}} ({{customer_email}}) has just signed contract <strong>{{contract_number}}</strong>.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>
<p style="font-size: 13px; color: #666;">The signed PDF and signature evidence (typed name, IP, timestamp, signature image if drawn) are available on the contract detail page. To make this fully binding, counter-sign the contract or upload a wet-signed copy.</p>`,
body_text: `Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}`,
},
de: {
subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet',
body_html: `<h2>Vertrag unterzeichnet</h2><p>{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag <strong>{{contract_number}}</strong> unterzeichnet.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>
<p style="font-size: 13px; color: #666;">Das unterzeichnete PDF und die Signatur-Belege (Name, IP, Zeitstempel, Signaturbild falls gezeichnet) sind auf der Vertragsdetailseite einsehbar. Für vollständige Verbindlichkeit unterzeichnen Sie den Vertrag gegen oder laden Sie eine handunterschriebene Kopie hoch.</p>`,
body_text: `Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}`,
},
},
};
// Cache the "all-seeded" state so the check is free after the first
// successful run. Reset to false on insertion failure so subsequent
// calls retry.
let _seeded = false;
/**
* Insert any missing contract email templates into email_templates +
* email_template_translations. Safe to call concurrently each
* row's existence check happens inline before insert.
*
* Returns the list of templateKeys that were newly inserted (for
* logging / diagnostics). Empty array = all templates already exist.
*/
async function ensureContractEmailTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const newlyInserted = [];
for (const [templateKey, def] of Object.entries(CONTRACT_EMAIL_TEMPLATES)) {
const existing = await db('email_templates').where({ template_key: templateKey }).first();
if (existing) continue;
const enContent = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(def.variables),
};
if ('category' in cols) masterRow.category = def.category;
if ('subcategory' in cols) masterRow.subcategory = null;
if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag;
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill any subject_<lang> / body_html_<lang> / body_text_<lang>
// shaped columns the install happens to have (legacy variants vs
// the modern email_template_translations table).
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;
}
}
try {
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
if (hasTranslationsTable && templateId) {
for (const lang of ['en', 'de']) {
const content = def[lang];
if (!content) continue;
await db('email_template_translations').insert({
template_id: templateId,
language: lang,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
}
newlyInserted.push(templateKey);
if (logger) {
logger.info(`Self-healed missing contract email template at runtime: ${templateKey}`);
}
} catch (err) {
// Keep _seeded=false so the next call retries. Don't throw —
// the caller (queueEmail upstream) will surface its own error
// if the template still can't be looked up.
if (logger) {
logger.error(`Failed to seed contract email template ${templateKey}`, {
message: err.message,
});
}
return newlyInserted;
}
}
_seeded = true;
return newlyInserted;
}
module.exports = {
CONTRACT_EMAIL_TEMPLATES,
ensureContractEmailTemplatesSeeded,
};
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
/**
* CRM email template definitions (quotes / invoices / Storno / payment-check
* / paid-admin-notification) runtime self-heal seeder.
*
* Original sources: migrations 102 (8 templates), 112 (quote_accepted_customer),
* 116 (invoice_payment_check), 122 (storno_issued), 127 (invoice_paid_admin_notification).
*
* The consolidated migration (107_crm_consolidated.js) owns SCHEMA only;
* this service file owns CONTENT. `ensureCrmEmailTemplatesSeeded()` is
* idempotent call it from server boot, GET /admin/email/templates,
* and any code path about to send one of these templates. Missing
* rows get inserted; existing rows are LEFT ALONE so admin edits are
* never overwritten.
*
* Same pattern as contractEmailTemplates.js + eventReminderTemplates.js
* per the maintainer's "never ship compensation migrations" rule,
* we self-heal at runtime instead of bolting content into the schema diff.
*
* Translations: en + de hand-translated; fr/nl/pt/ru intentionally
* absent. Renderer falls through to en until admin overrides via the
* Templates UI. Flag for native review in the PR description.
*/
const CRM_EMAIL_TEMPLATES = {
quote_sent: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_name', 'response_url', 'accept_url', 'decline_url',
'valid_until', 'event_name', 'total_amount'],
en: {
subject: 'Your quote {{quote_number}} is ready',
body_html: `<h2>Quote {{quote_number}}</h2>
<p>Dear {{customer_name}},</p>
<p>Please find the attached quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}. Total amount: <strong>{{total_amount}}</strong>.</p>
<p>You can accept or decline this quote directly via the buttons below:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{accept_url}}" class="button">Accept quote</a>
&nbsp;
<a href="{{decline_url}}" style="display:inline-block;padding:10px 20px;color:#666;text-decoration:underline;">Decline</a>
</p>
<p>Or open the full quote in your browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">This quote is valid until {{valid_until}}.</p>{{/if}}`,
body_text: `Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}`,
},
de: {
subject: 'Ihr Angebot {{quote_number}} ist bereit',
body_html: `<h2>Angebot {{quote_number}}</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}}. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
<p>Sie können das Angebot direkt über die Schaltflächen unten annehmen oder ablehnen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{accept_url}}" class="button">Angebot annehmen</a>
&nbsp;
<a href="{{decline_url}}" style="display:inline-block;padding:10px 20px;color:#666;text-decoration:underline;">Ablehnen</a>
</p>
<p>Oder öffnen Sie das vollständige Angebot im Browser:<br>
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Dieses Angebot ist gültig bis {{valid_until}}.</p>{{/if}}`,
body_text: `Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}`,
},
},
quote_accepted_admin: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_email', 'event_name', 'total_amount', 'admin_dashboard_url'],
en: {
subject: 'Quote {{quote_number}} accepted by {{customer_email}}',
body_html: `<h2>Quote accepted</h2><p>{{customer_email}} just accepted quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>`,
body_text: `Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}`,
},
de: {
subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen',
body_html: `<h2>Angebot angenommen</h2><p>{{customer_email}} hat soeben das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.</p>
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>`,
body_text: `Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}`,
},
},
quote_declined_admin: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_email', 'event_name', 'admin_dashboard_url'],
en: {
subject: 'Quote {{quote_number}} declined by {{customer_email}}',
body_html: `<p>{{customer_email}} declined quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
<p><a href="{{admin_dashboard_url}}">Open quote in admin</a></p>`,
body_text: `Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}`,
},
de: {
subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt',
body_html: `<p>{{customer_email}} hat das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.</p>
<p><a href="{{admin_dashboard_url}}">Angebot im Admin-Bereich öffnen</a></p>`,
body_text: `Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}`,
},
},
invoice_sent: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'due_date',
'installment_label', 'installment_index', 'installment_total'],
en: {
subject: 'Invoice {{invoice_number}} — {{total_amount}}',
body_html: `<h2>Invoice {{invoice_number}}</h2><p>Dear {{customer_name}},</p>
<p>Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
<p><strong>Amount:</strong> {{total_amount}}<br><strong>Due:</strong> {{due_date}}{{#if installment_label}}<br><strong>Installment:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
<p>The payment details and IBAN are on the attached PDF.</p>`,
body_text: `Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.`,
},
de: {
subject: 'Rechnung {{invoice_number}} — {{total_amount}}',
body_html: `<h2>Rechnung {{invoice_number}}</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p>
<p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
<p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`,
body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`,
},
},
invoice_reminder_first: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue'],
en: {
subject: 'Reminder: invoice {{invoice_number}} is overdue',
body_html: `<h2>Payment reminder</h2><p>Dear {{customer_name}},</p>
<p>Our records show that invoice <strong>{{invoice_number}}</strong> (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is <strong>{{total_amount}}</strong>.</p>
<p>If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.</p>`,
body_text: `Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.`,
},
de: {
subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}',
body_html: `<h2>Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>laut unseren Unterlagen ist die Rechnung <strong>{{invoice_number}}</strong> (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt <strong>{{total_amount}}</strong>.</p>
<p>Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.</p>`,
body_text: `Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.`,
},
},
invoice_reminder_second: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue',
'late_fee_amount', 'new_total_amount'],
en: {
subject: 'Second reminder: invoice {{invoice_number}}',
body_html: `<h2>Second payment reminder</h2><p>Dear {{customer_name}},</p>
<p>Invoice <strong>{{invoice_number}}</strong> is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of <strong>{{late_fee_amount}}</strong> has been added. The new total is <strong>{{new_total_amount}}</strong>.</p>
<p>Please settle the outstanding amount as soon as possible. A revised invoice is attached.</p>`,
body_text: `Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.`,
},
de: {
subject: 'Zweite Mahnung: Rechnung {{invoice_number}}',
body_html: `<h2>Zweite Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>die Rechnung <strong>{{invoice_number}}</strong> ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von <strong>{{late_fee_amount}}</strong> hinzugefügt. Der neue Gesamtbetrag beträgt <strong>{{new_total_amount}}</strong>.</p>
<p>Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.</p>`,
body_text: `Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.`,
},
},
invoice_paid_receipt: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'paid_amount', 'paid_at'],
en: {
subject: 'Receipt for invoice {{invoice_number}}',
body_html: `<h2>Payment received</h2><p>Dear {{customer_name}},</p>
<p>We received your payment of <strong>{{paid_amount}}</strong> for invoice {{invoice_number}} on {{paid_at}}. Thank you!</p>`,
body_text: `Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.`,
},
de: {
subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}',
body_html: `<h2>Zahlung erhalten</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>vielen Dank für Ihre Zahlung in Höhe von <strong>{{paid_amount}}</strong> für die Rechnung {{invoice_number}} am {{paid_at}}.</p>`,
body_text: `Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.`,
},
},
invoice_cancelled: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name'],
en: {
subject: 'Invoice {{invoice_number}} cancelled',
body_html: `<p>Dear {{customer_name}},</p><p>Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.</p>`,
body_text: `Invoice {{invoice_number}} has been cancelled.`,
},
de: {
subject: 'Rechnung {{invoice_number}} storniert',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p><p>die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.</p>`,
body_text: `Rechnung {{invoice_number}} wurde storniert.`,
},
},
quote_accepted_customer: {
category: 'quotes',
feature_flag: 'quotes',
variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'],
en: {
subject: 'Quote {{quote_number}} accepted — thank you',
body_html: `<h2>Thank you</h2>
<p>Dear {{customer_name}},</p>
<p>This confirms that quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: <strong>{{total_amount}}</strong>.</p>
{{#if accepted_on_behalf}}<p style="font-size: 13px; color: #666;">This acceptance was recorded on your behalf by your photographer.</p>{{/if}}
<p>We'll be in touch with next steps shortly.</p>`,
body_text: `Dear {{customer_name}},
This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}.
{{#if accepted_on_behalf}}
This acceptance was recorded on your behalf by your photographer.
{{/if}}
We'll be in touch with next steps shortly.`,
},
de: {
subject: 'Angebot {{quote_number}} angenommen — vielen Dank',
body_html: `<h2>Vielen Dank</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>hiermit bestätigen wir, dass das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für {{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
{{#if accepted_on_behalf}}<p style="font-size: 13px; color: #666;">Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.</p>{{/if}}
<p>Wir melden uns in Kürze mit den nächsten Schritten.</p>`,
body_text: `Sehr geehrte/r {{customer_name}},
hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}.
{{#if accepted_on_behalf}}
Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.
{{/if}}
Wir melden uns in Kürze mit den nächsten Schritten.`,
},
},
invoice_payment_check: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'event_name', 'due_date', 'total_amount', 'paid_url', 'partial_url', 'unpaid_url', 'skonto_url', 'has_skonto', 'skonto_amount', 'late_fee_due', 'late_fee_amount'],
en: {
subject: 'Check payment for invoice {{invoice_number}}',
body_html: `<h2>Time to check on a payment</h2>
<p>Invoice <strong>{{invoice_number}}</strong> for <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} was due on <strong>{{due_date}}</strong>. Total: <strong>{{total_amount}}</strong>.</p>
<p>Please check your bank to confirm what (if anything) has been received, then click the matching button below no login required.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 24px auto; border-collapse: collapse;">
<tr>
<td style="padding: 0 6px;">
<a href="{{paid_url}}" style="background: #16a34a; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Paid in full</a>
</td>
{{#if has_skonto}}<td style="padding: 0 6px;">
<a href="{{skonto_url}}" style="background: #0d9488; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Paid with Skonto ({{skonto_amount}})</a>
</td>{{/if}}
<td style="padding: 0 6px;">
<a href="{{partial_url}}" style="background: #2563eb; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Partially paid</a>
</td>
<td style="padding: 0 6px;">
<a href="{{unpaid_url}}" style="background: #dc2626; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Not paid yet</a>
</td>
</tr>
</table>
<p style="font-size: 13px; color: #666;">If you select "Not paid yet" or "Partially paid", the system will queue the next reminder to the customer{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.</p>`,
body_text: `Time to check on a payment
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}.
Confirm what was received:
Paid in full: {{paid_url}}{{#if has_skonto}}
Paid with Skonto ({{skonto_amount}}): {{skonto_url}}{{/if}}
Partial: {{partial_url}}
Not paid yet: {{unpaid_url}}
Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.`,
},
de: {
subject: 'Zahlung prüfen für Rechnung {{invoice_number}}',
body_html: `<h2>Zahlung prüfen</h2>
<p>Rechnung <strong>{{invoice_number}}</strong> für <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} war am <strong>{{due_date}}</strong> fällig. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
<p>Bitte prüfen Sie auf Ihrem Konto, was eingegangen ist, und klicken Sie unten den passenden Button kein Login nötig.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 24px auto; border-collapse: collapse;">
<tr>
<td style="padding: 0 6px;">
<a href="{{paid_url}}" style="background: #16a34a; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Vollständig bezahlt</a>
</td>
{{#if has_skonto}}<td style="padding: 0 6px;">
<a href="{{skonto_url}}" style="background: #0d9488; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Mit Skonto bezahlt ({{skonto_amount}})</a>
</td>{{/if}}
<td style="padding: 0 6px;">
<a href="{{partial_url}}" style="background: #2563eb; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Teilweise bezahlt</a>
</td>
<td style="padding: 0 6px;">
<a href="{{unpaid_url}}" style="background: #dc2626; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Nicht bezahlt</a>
</td>
</tr>
</table>
<p style="font-size: 13px; color: #666;">Bei Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung an den Kunden gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.</p>`,
body_text: `Zahlung prüfen
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}.
Bitte bestätigen:
Vollständig bezahlt: {{paid_url}}{{#if has_skonto}}
Mit Skonto bezahlt ({{skonto_amount}}): {{skonto_url}}{{/if}}
Teilweise: {{partial_url}}
Nicht bezahlt: {{unpaid_url}}
Bei Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.`,
},
},
storno_issued: {
category: 'billing', feature_flag: 'bills',
variables: ['storno_number', 'original_invoice_number', 'original_issue_date', 'customer_name', 'total_amount'],
en: {
subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}',
body_html: `<p>Dear {{customer_name}},</p>
<p>Please find attached cancellation invoice <strong>{{storno_number}}</strong>, which formally reverses invoice <strong>{{original_invoice_number}}</strong> dated {{original_issue_date}} for {{total_amount}}.</p>
<p>The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.</p>`,
body_text: `Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.`,
},
de: {
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
<p>anbei erhalten Sie die Stornorechnung <strong>{{storno_number}}</strong>, mit der die Rechnung <strong>{{original_invoice_number}}</strong> vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.</p>
<p>Die ursprüngliche Rechnung ist damit nicht mehr zu begleichen. Bitte bewahren Sie die beigefügte PDF für Ihre Unterlagen auf etwaige vorherige Mahnungen sind hinfällig.</p>`,
body_text: `Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.`,
},
},
invoice_paid_admin_notification: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'paid_amount', 'paid_at', 'payment_method', 'payment_reference', 'skonto_applied', 'skonto_percent', 'skonto_discount_amount'],
en: {
subject: 'Payment received: invoice {{invoice_number}}',
body_html: `<h2>Payment recorded</h2>
<p>Invoice <strong>{{invoice_number}}</strong> for <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.</p>
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="color: #666;">Total invoice amount</td><td><strong>{{total_amount}}</strong></td></tr>
<tr><td style="color: #666;">Paid total</td><td><strong>{{paid_amount}}</strong></td></tr>
{{#if skonto_applied}}<tr><td style="color: #0d9488;">Paid with Skonto ({{skonto_percent}}%)</td><td style="color: #0d9488;"><strong>{{skonto_discount_amount}}</strong></td></tr>{{/if}}
{{#if payment_method}}<tr><td style="color: #666;">Payment method</td><td>{{payment_method}}</td></tr>{{/if}}
{{#if payment_reference}}<tr><td style="color: #666;">Reference</td><td>{{payment_reference}}</td></tr>{{/if}}
<tr><td style="color: #666;">Recorded at</td><td>{{paid_at}}</td></tr>
</table>
<p style="font-size: 13px; color: #666;">This is an automatic notification no action required.</p>`,
body_text: `Payment recorded
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.
Total invoice amount: {{total_amount}}
Paid total: {{paid_amount}}{{#if skonto_applied}}
Paid with Skonto ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}}
Payment method: {{payment_method}}{{/if}}{{#if payment_reference}}
Reference: {{payment_reference}}{{/if}}
Recorded at: {{paid_at}}
This is an automatic notification no action required.`,
},
de: {
subject: 'Zahlung erhalten: Rechnung {{invoice_number}}',
body_html: `<h2>Zahlung erfasst</h2>
<p>Rechnung <strong>{{invoice_number}}</strong> für <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.</p>
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="color: #666;">Rechnungsbetrag</td><td><strong>{{total_amount}}</strong></td></tr>
<tr><td style="color: #666;">Eingezahlt</td><td><strong>{{paid_amount}}</strong></td></tr>
{{#if skonto_applied}}<tr><td style="color: #0d9488;">Mit Skonto bezahlt ({{skonto_percent}}%)</td><td style="color: #0d9488;"><strong>{{skonto_discount_amount}}</strong></td></tr>{{/if}}
{{#if payment_method}}<tr><td style="color: #666;">Zahlungsart</td><td>{{payment_method}}</td></tr>{{/if}}
{{#if payment_reference}}<tr><td style="color: #666;">Referenz</td><td>{{payment_reference}}</td></tr>{{/if}}
<tr><td style="color: #666;">Erfasst am</td><td>{{paid_at}}</td></tr>
</table>
<p style="font-size: 13px; color: #666;">Automatische Benachrichtigung keine Aktion erforderlich.</p>`,
body_text: `Zahlung erfasst
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.
Rechnungsbetrag: {{total_amount}}
Eingezahlt: {{paid_amount}}{{#if skonto_applied}}
Mit Skonto bezahlt ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}}
Zahlungsart: {{payment_method}}{{/if}}{{#if payment_reference}}
Referenz: {{payment_reference}}{{/if}}
Erfasst am: {{paid_at}}
Automatische Benachrichtigung keine Aktion erforderlich.`,
},
},
};
let _seeded = false;
/**
* Insert any missing CRM email templates into email_templates +
* email_template_translations. Idempotent: existing template_keys are
* left alone so admin customisations are never clobbered.
*
* Returns the list of templateKeys newly inserted (for logging).
*/
async function ensureCrmEmailTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const newlyInserted = [];
for (const [templateKey, def] of Object.entries(CRM_EMAIL_TEMPLATES)) {
const existing = await db('email_templates').where({ template_key: templateKey }).first();
if (existing) continue;
const enContent = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(def.variables),
};
if ('category' in cols) masterRow.category = def.category;
if ('subcategory' in cols) masterRow.subcategory = null;
if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag;
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill legacy subject_<lang> / body_html_<lang> / body_text_<lang>
// columns when present (the modern translations table is populated
// below regardless).
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;
}
}
try {
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
if (hasTranslationsTable && templateId) {
for (const lang of ['en', 'de']) {
const content = def[lang];
if (!content) continue;
await db('email_template_translations').insert({
template_id: templateId,
language: lang,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
}
newlyInserted.push(templateKey);
if (logger) {
logger.info(`Self-healed missing CRM email template at runtime: ${templateKey}`);
}
} catch (err) {
// Keep _seeded=false so the next call retries. Don't throw —
// caller surfaces its own error if the template still can't be
// looked up.
if (logger) {
logger.error(`Failed to seed CRM email template ${templateKey}`, {
message: err.message,
});
}
return newlyInserted;
}
}
_seeded = true;
return newlyInserted;
}
module.exports = {
CRM_EMAIL_TEMPLATES,
ensureCrmEmailTemplatesSeeded,
};
+329 -55
View File
@@ -42,6 +42,12 @@ const PREFILLABLE_FIELDS = [
'city',
'state',
'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')
.where('email', normalisedEmail)
.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');
}
// 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')
.where('email', normalisedEmail)
@@ -158,6 +169,92 @@ async function createInvitation({ email, invitedById, prefill }) {
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
* 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');
}
// Race-condition guard: an admin may have created the customer manually
// (future flow) between the invite link being generated and clicked.
// Race-condition guard: an admin may have created the customer
// 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')
.where('email', invitation.email)
.first();
if (existing) {
if (existing && existing.password_hash) {
throw new ConflictError('Email already registered', 'email');
}
const promoting = !!existing && !existing.password_hash;
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
@@ -200,47 +306,105 @@ async function acceptInvitation({ token, name, password, profile }) {
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 [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,
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');
const id = inserted?.id || inserted;
let id;
if (promoting) {
// Promotion path: passive customer being claimed by the
// customer themselves via the invitation link. UPDATE the
// existing row (preserving id + all foreign-key relationships)
// instead of inserting. We merge the profile fields: anything
// the customer typed on the accept form wins; values they
// didn't touch leave the existing row untouched.
id = existing.id;
const updates = {
password_hash: passwordHash,
password_changed_at: null,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
updated_at: new Date(),
};
// Only overwrite profile fields when the merged payload
// actually carries a value — never blank out existing data
// (the customer might have left a field empty because the
// admin had pre-filled it correctly).
const overwriteIfSet = (key, col = key) => {
if (merged[key] != null && merged[key] !== '') updates[col] = merged[key];
};
overwriteIfSet('salutation');
overwriteIfSet('first_name');
overwriteIfSet('last_name');
overwriteIfSet('display_name');
overwriteIfSet('phone');
overwriteIfSet('company_name');
overwriteIfSet('vat_id');
overwriteIfSet('address_line1');
overwriteIfSet('address_line2');
overwriteIfSet('postal_code');
overwriteIfSet('city');
overwriteIfSet('state');
overwriteIfSet('country_code');
if (merged.preferred_language) updates.preferred_language = merged.preferred_language;
await trx('customer_accounts').where('id', id).update(updates);
} else {
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')
.where('id', invitation.id)
@@ -301,6 +465,22 @@ async function listCustomers({ search } = {}) {
'customer_accounts.salutation',
'customer_accounts.company_name',
'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.created_at',
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',
'phone', 'company_name', 'billing_email', 'vat_id',
'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
// 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) {
if (updates[f] !== undefined) {
@@ -377,8 +564,45 @@ async function updateCustomer(id, updates, updatedByAdminId) {
allowed[f] = String(updates[f] || '').trim().toLowerCase();
} else if (f === 'country_code' && updates[f]) {
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]);
} 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 {
allowed[f] = updates[f];
}
@@ -579,7 +803,22 @@ async function searchCustomers(query, { limit = 10 } = {}) {
.orWhereRaw('LOWER(COALESCE(last_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')
.limit(limit);
}
@@ -970,10 +1209,25 @@ async function getCustomerSurfaceGlobals() {
}
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 {
calendarEnabled: false,
quotesEnabled: false,
billsEnabled: false,
calendarEnabled: readBool('customer_feature_calendar_enabled', false),
quotesEnabled: readBool('customer_feature_quotes_enabled', true),
billsEnabled: readBool('customer_feature_bills_enabled', true),
showLogo: map.customer_show_logo !== 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()
: customerOrId;
if (!customer) {
return { calendar: false, quotes: false, bills: false };
return { calendar: false, quotes: false, bills: false, hoursLogging: false, contracts: false };
}
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 {
calendar: globals.calendarEnabled && customer.feature_calendar === true,
quotes: globals.quotesEnabled && customer.feature_quotes === true,
bills: globals.billsEnabled && customer.feature_bills === true,
calendar: globals.calendarEnabled && truthy(customer.feature_calendar),
quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
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 = {
createInvitation,
createDirect,
acceptInvitation,
validateInvitationToken,
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,
},
};
+178
View File
@@ -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,
};
+76 -13
View File
@@ -109,8 +109,29 @@ async function getRecipientLanguage(email, eventId = null) {
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 {
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
@@ -124,7 +145,7 @@ async function getRecipientLanguage(email, eventId = null) {
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 {
const emailConfig = await db('email_configs').first();
if (emailConfig && emailConfig.default_language) {
@@ -133,8 +154,8 @@ async function getRecipientLanguage(email, eventId = null) {
} catch (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) {
const domain = email.toLowerCase();
const domainLanguageMap = [
@@ -675,13 +696,34 @@ async function sendTemplateEmail(to, templateKey, variables) {
// Process template with variables
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
const info = await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: to,
cc: ccList,
subject: subject,
html: htmlBody,
text: textBody || htmlToText(htmlBody)
text: textBody || htmlToText(htmlBody),
attachments,
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
@@ -709,9 +751,17 @@ async function processEmailQueue() {
let pendingEmails = [];
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')
.where('status', 'pending')
.where('retry_count', '<', 3)
.andWhere(function() {
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
})
.orderBy('scheduled_at', 'asc')
.orderBy('created_at', 'asc')
.limit(10);
} catch (dbError) {
@@ -776,22 +826,35 @@ async function processEmailQueue() {
}
}
// Queue an email for sending
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
// Queue an email for sending. Optionally takes a 5th `options` arg:
// 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 {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
await db('email_queue').insert({
const row = {
event_id: eventId,
recipient_email: recipientEmail,
email_type: emailType,
email_data: JSON.stringify(emailData),
status: 'pending',
retry_count: 0,
created_at: new Date()
});
logger.info(`Email queued: ${emailType} to ${recipientEmail}`);
created_at: new Date(),
};
if (options.scheduledAt) {
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) {
logger.error('Error queueing email:', 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,
},
};
@@ -0,0 +1,333 @@
/**
* Pre-event customer reminder templates definitions + runtime self-heal.
*
* Migration 143 originally seeded an empty `event_reminder_default` row.
* That left admins staring at a blank editor and had no per-event-type
* variants. Per the maintainer's "never ship compensation migrations" rule
* (see contractEmailTemplates.js for the same pattern), we self-heal at
* runtime instead of bolting on a follow-up migration.
*
* `ensureEventReminderTemplatesSeeded(db, logger)` is idempotent call
* it as often as you like:
* - Missing template_keys get inserted with EN+DE example content.
* - Existing template_keys whose EN translation is entirely empty
* (the legacy migration-143 case) are backfilled with the example
* content. Translations that already have any subject/body content
* are LEFT ALONE so an admin's customisations never get clobbered.
*
* Process-level boolean caches the "all templates verified" state once
* we've made one successful pass, so the cron's hourly retick is free.
*
* Variables expected on every template: customer_name, event_name,
* event_date, event_type, days_before, business_name. Keep this list in
* sync with eventReminderService.composePayload.
*
* Per-type template keys (`event_reminder_<slug_prefix>`) are seeded for
* the four SYSTEM event_types from migration 061: wedding, birthday,
* corporate, other. Admins who add custom event_types via the Event
* Types settings page get no seeded body they author their own via
* the Reminder Emails tab (the "Default" pill on the sidebar makes
* obvious which types are still riding the catch-all).
*/
const VARIABLES = [
'customer_name', 'event_name', 'event_date',
'event_type', 'days_before', 'business_name',
];
// Tiny HTML signature line shared across templates so the maintainer
// only has to brand once. Variables substitute at render time.
const SIGNATURE_EN = `<p style="margin-top: 24px;">See you soon,<br>{{business_name}}</p>`;
const SIGNATURE_DE = `<p style="margin-top: 24px;">Bis bald,<br>{{business_name}}</p>`;
const EVENT_REMINDER_TEMPLATES = {
event_reminder_default: {
en: {
subject: 'Reminder: {{event_name}} in {{days_before}} day(s)',
body_html: `<p>Hi {{customer_name}},</p>
<p>Just a quick reminder that <strong>{{event_name}}</strong> is coming up on <strong>{{event_date}}</strong> about {{days_before}} day(s) from now.</p>
<p>A few things that help us hit the ground running on the day:</p>
<ul>
<li>Confirm the exact start time and address (a what3words pin works great).</li>
<li>Let us know if there is anything we should keep an eye on VIPs, surprise moments, restricted areas.</li>
<li>Indoor venues: a small corner for equipment setup is a huge help.</li>
</ul>
<p>If anything has changed since we last spoke, just hit reply.</p>
${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`,
},
de: {
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
body_html: `<p>Hallo {{customer_name}},</p>
<p>kurze Erinnerung: <strong>{{event_name}}</strong> findet am <strong>{{event_date}}</strong> statt in etwa {{days_before}} Tag(en).</p>
<p>Damit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:</p>
<ul>
<li>Genaue Startzeit und Adresse bestätigen (gerne auch ein what3words-Pin).</li>
<li>Kurz Bescheid geben, falls etwas besonders zu beachten ist VIPs, Überraschungsmomente, abgesperrte Bereiche.</li>
<li>Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.</li>
</ul>
<p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p>
${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}`,
},
},
event_reminder_wedding: {
en: {
subject: 'Your wedding on {{event_date}} — last details',
body_html: `<p>Dear {{customer_name}},</p>
<p>Your wedding day is almost here <strong>{{event_date}}</strong>, in about {{days_before}} day(s). We are very much looking forward to it.</p>
<p>A short pre-day checklist so the photo coverage flows smoothly:</p>
<ul>
<li><strong>Timeline:</strong> a rough hour-by-hour run-of-day (getting ready ceremony portraits reception party) helps us anticipate every moment.</li>
<li><strong>Family shots:</strong> a short list of must-have group photos (with names) keeps the formals quick and stress-free.</li>
<li><strong>Getting-ready space:</strong> a room with natural light (window-side) makes a real difference.</li>
<li><strong>Surprises:</strong> let us know about any surprises so we are in the right place at the right moment and won't accidentally spoil them.</li>
<li><strong>Logistics:</strong> ceremony start time, venue address, parking notes, and contact number for the day-of coordinator.</li>
</ul>
<p>If anything has shifted since we last spoke even small things just hit reply.</p>
${SIGNATURE_EN}`,
body_text: `Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won't spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`,
},
de: {
subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
body_html: `<p>Liebe/r {{customer_name}},</p>
<p>euer grosser Tag steht fast vor der Tür <strong>{{event_date}}</strong>, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.</p>
<p>Eine kurze Checkliste vor dem Tag, damit die fotografische Begleitung reibungslos läuft:</p>
<ul>
<li><strong>Ablauf:</strong> ein grober Stunden-Ablauf (Getting-Ready Trauung Portraits Empfang Party) hilft uns enorm, jeden Moment einzuplanen.</li>
<li><strong>Familienbilder:</strong> eine kurze Liste der Wunsch-Gruppenbilder (mit Namen) hält die Formalitäten knapp und entspannt.</li>
<li><strong>Getting-Ready-Raum:</strong> ein Zimmer mit Tageslicht (Fensterseite) macht einen riesigen Unterschied.</li>
<li><strong>Überraschungen:</strong> kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind und nichts versehentlich verraten.</li>
<li><strong>Logistik:</strong> Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.</li>
</ul>
<p>Hat sich seit unserem letzten Gespräch etwas verschoben auch Kleinigkeiten? Einfach kurz antworten.</p>
${SIGNATURE_DE}`,
body_text: `Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}`,
},
},
event_reminder_birthday: {
en: {
subject: '{{event_name}} on {{event_date}} — quick check-in',
body_html: `<p>Hi {{customer_name}},</p>
<p>{{event_name}} is coming up on <strong>{{event_date}}</strong> about {{days_before}} day(s) away. Quick check-in before the day:</p>
<ul>
<li><strong>Headcount:</strong> roughly how many guests should we expect? Helps us plan group shots and candid coverage.</li>
<li><strong>Schedule:</strong> when is the cake/song moment? We always want to be ready for that one.</li>
<li><strong>Theme or dress code:</strong> if there is one, let us know so we can match the vibe.</li>
<li><strong>Surprises:</strong> any surprise guests or moments we should keep quiet about?</li>
</ul>
<p>Looking forward to celebrating let us know if anything has changed.</p>
${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}`,
},
de: {
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
body_html: `<p>Hallo {{customer_name}},</p>
<p>{{event_name}} steht am <strong>{{event_date}}</strong> an in etwa {{days_before}} Tag(en). Kurze Rückfrage vor dem Tag:</p>
<ul>
<li><strong>Personenzahl:</strong> wie viele Gäste werden in etwa kommen? Hilft uns bei Gruppenbildern und der Candid-Strecke.</li>
<li><strong>Ablauf:</strong> wann ist der Torten-/Ständchen-Moment? Den möchten wir auf keinen Fall verpassen.</li>
<li><strong>Motto oder Dresscode:</strong> falls vorhanden, gerne kurz Bescheid geben, damit wir die Stimmung treffen.</li>
<li><strong>Überraschungen:</strong> Gäste oder Momente, über die wir nicht reden sollten?</li>
</ul>
<p>Wir freuen uns auf das Fest kurz Bescheid geben, falls sich etwas geändert hat.</p>
${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`,
},
},
event_reminder_corporate: {
en: {
subject: 'Coverage prep: {{event_name}} on {{event_date}}',
body_html: `<p>Dear {{customer_name}},</p>
<p>{{event_name}} is on <strong>{{event_date}}</strong> about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:</p>
<ul>
<li><strong>Shot brief:</strong> what is the photography for internal comms, press kit, social, website? It affects framing and crops.</li>
<li><strong>Agenda / run-of-show:</strong> who is speaking when, plus any moments worth flagging (awards, panels, Q&amp;A).</li>
<li><strong>VIPs &amp; brand:</strong> a short list of names to prioritise, plus the logo/colour direction so we keep the deck consistent.</li>
<li><strong>Access:</strong> entrance, loading dock if any, on-site contact for the morning. Photo IDs or accreditation needed?</li>
<li><strong>Confidentiality:</strong> any sessions that are strictly internal / no-photo?</li>
<li><strong>Delivery:</strong> rough turnaround you need (24h press selects, full gallery later)?</li>
</ul>
<p>Happy to jump on a 10-min call beforehand if it is easier than email.</p>
${SIGNATURE_EN}`,
body_text: `Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}`,
},
de: {
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
<p>{{event_name}} findet am <strong>{{event_date}}</strong> statt in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:</p>
<ul>
<li><strong>Briefing:</strong> wofür sind die Bilder interne Kommunikation, Pressekit, Social, Website? Hat Einfluss auf Bildausschnitt und Format.</li>
<li><strong>Agenda / Ablauf:</strong> wer spricht wann, sowie besondere Momente (Awards, Panels, Q&amp;A).</li>
<li><strong>VIPs &amp; Brand:</strong> kurze Liste der zu priorisierenden Personen, plus Logo-/Farbvorgaben für eine konsistente Bildsprache.</li>
<li><strong>Zugang:</strong> Eingang, ggf. Anlieferung, Ansprechperson am Morgen. Lichtbildausweis oder Akkreditierung nötig?</li>
<li><strong>Vertraulichkeit:</strong> Sessions, die ausschliesslich intern sind / kein Foto?</li>
<li><strong>Lieferung:</strong> grobe Vorgabe zur Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?</li>
</ul>
<p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p>
${SIGNATURE_DE}`,
body_text: `Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}`,
},
},
event_reminder_other: {
en: {
subject: '{{event_name}} on {{event_date}} — prep notes',
body_html: `<p>Hi {{customer_name}},</p>
<p>{{event_name}} is on <strong>{{event_date}}</strong> about {{days_before}} day(s) away. A short prep note:</p>
<ul>
<li><strong>Start time &amp; address:</strong> please confirm both even small changes matter for arrival/setup.</li>
<li><strong>Run-of-day:</strong> a rough timeline of the key moments (start, highlights, end) helps us be in the right place.</li>
<li><strong>Setup space:</strong> if indoors, a small corner for gear makes a real difference.</li>
<li><strong>Anything specific:</strong> people to prioritise, things to avoid, dress code, surprises just let us know.</li>
</ul>
<p>If anything has changed since we last spoke, hit reply.</p>
${SIGNATURE_EN}`,
body_text: `Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}`,
},
de: {
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
body_html: `<p>Hallo {{customer_name}},</p>
<p>{{event_name}} findet am <strong>{{event_date}}</strong> statt in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:</p>
<ul>
<li><strong>Startzeit &amp; Adresse:</strong> bitte beides kurz bestätigen auch kleine Änderungen sind für Anreise/Aufbau wichtig.</li>
<li><strong>Ablauf:</strong> ein grober Zeitplan der Schlüsselmomente (Start, Highlights, Ende) hilft uns bei der Positionierung.</li>
<li><strong>Aufbauplatz:</strong> bei Innen-Locations ist eine kleine Ecke fürs Equipment Gold wert.</li>
<li><strong>Besonderheiten:</strong> Personen, die im Fokus stehen sollen, Dinge, die vermieden werden sollen, Dresscode, Überraschungen gerne kurz Bescheid geben.</li>
</ul>
<p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p>
${SIGNATURE_DE}`,
body_text: `Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`,
},
},
};
let _seeded = false;
/**
* Idempotent seed/backfill for every entry in EVENT_REMINDER_TEMPLATES.
* Safe to call repeatedly both at boot and inside the cron tick.
*
* Rules:
* - Missing template_key insert master row + translations.
* - Existing template_key whose EN translation is entirely empty
* (subject + body_html + body_text all blank) backfill EN+DE.
* This matches the legacy migration-143 "empty seed" case without
* ever touching admin-customised content.
* - Existing template_key with non-empty EN translation leave alone.
*
* Returns array of template_keys touched (inserted or backfilled) for
* diagnostic logging.
*/
async function ensureEventReminderTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const touched = [];
const isEmpty = (tr) => {
if (!tr) return true;
const s = (tr.subject || '').trim();
const h = (tr.body_html || '').trim();
const t = (tr.body_text || '').trim();
return !s && !h && !t;
};
const upsertTranslation = async (templateId, language, content) => {
if (!hasTranslationsTable) return;
const existing = await db('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing && !isEmpty(existing)) return; // never overwrite admin edits
if (existing) {
await db('email_template_translations')
.where({ id: existing.id })
.update({
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
updated_at: new Date(),
});
} else {
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(),
});
}
};
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
try {
let existing = await db('email_templates').where({ template_key: templateKey }).first();
if (!existing) {
const en = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(VARIABLES),
};
if ('category' in cols) masterRow.category = 'crm';
if ('subcategory' in cols) masterRow.subcategory = 'event_reminder';
if ('feature_flag' in cols) masterRow.feature_flag = 'crm_event_reminders_enabled';
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill legacy subject_<lang>/body_html_<lang> columns if present.
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.subject;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.body_html;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.body_text;
}
}
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await upsertTranslation(templateId, 'en', def.en);
await upsertTranslation(templateId, 'de', def.de);
touched.push(templateKey);
if (logger) logger.info(`Self-healed event reminder template: ${templateKey}`);
continue;
}
// Template exists — backfill empty translations only.
if (hasTranslationsTable) {
const en = await db('email_template_translations')
.where({ template_id: existing.id, language: 'en' })
.first();
if (isEmpty(en)) {
await upsertTranslation(existing.id, 'en', def.en);
await upsertTranslation(existing.id, 'de', def.de);
touched.push(templateKey);
if (logger) logger.info(`Self-healed empty event reminder translations: ${templateKey}`);
}
}
} catch (err) {
if (logger) {
logger.error(`Failed to seed event reminder template ${templateKey}`, {
message: err.message,
});
}
// Keep _seeded=false so the next pass retries.
return touched;
}
}
_seeded = true;
return touched;
}
module.exports = {
EVENT_REMINDER_TEMPLATES,
ensureEventReminderTemplatesSeeded,
};
+91 -2
View File
@@ -11,10 +11,49 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
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');
@@ -140,11 +179,21 @@ const createEvent = async (eventData) => {
allow_user_uploads,
upload_category_id,
// 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;
const requirePassword = parseBooleanInput(require_password, true);
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
if (requirePassword) {
@@ -214,6 +263,15 @@ const createEvent = async (eventData) => {
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
Object.keys(insertData).forEach(key => {
if (insertData[key] === undefined) {
@@ -360,6 +418,33 @@ const updateEvent = async (id, updates) => {
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);
return { success: true };
@@ -412,5 +497,9 @@ module.exports = {
mapEventForApi,
hasCustomerContactColumns,
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
};
+8 -3
View File
@@ -118,12 +118,17 @@ function frontendBase() {
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;
try {
const d = new Date(value);
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 {
return null;
}
@@ -147,7 +152,7 @@ async function buildOgMetadata(slug, requestPath) {
}
const eventName = event.event_name || 'Photo Gallery';
const eventDate = formatEventDate(event.event_date);
const eventDate = await formatEventDate(event.event_date);
const titleParts = [eventName];
if (siteName && siteName !== eventName) titleParts.push(siteName);
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
+523
View File
@@ -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
+361
View File
@@ -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
+760
View File
@@ -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 },
};
+38
View File
@@ -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 };
+35
View File
@@ -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 };
+22 -1
View File
@@ -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 = {
formatDate
formatDate,
formatShortDate,
};
+93
View File
@@ -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 };
+134
View File
@@ -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 1534 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 1534 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 },
};
+34
View File
@@ -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 };
+79
View File
@@ -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 };
+157
View File
@@ -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 },
};

Some files were not shown because too many files have changed in this diff Show More